Basics of Writing a Module

This page is intended to walk you through the basics of writing a module. Here we focus purely on writing the module, not designing it. Because of how PluginPlay works designing a module can sometimes be non-trivial; tips for designing a module can be found in Designing a Module. This page assumes we are writing a module which computes the electric field, \(\vec{E}\left(\vec{r}\right)\) of a series of point charges as detailed in Module Development.

Declaring the Module

In PluginPlay modules are implemented by inheriting from the ModuleBase class and then implementing a constructor and a run_ member. The declaration amounts to a large amount of boilerplate so PluginPlay provides a macro DECLARE_MODULE(T) which will declare a class T satisfying the required API. Since it will be used as the name of the class, T must be a valid C identifier.

Generally speaking modules of the same property type differ by the algorithm used to compute the property. It thus makes sense to somehow include the algorithm in the module’s name. For the purposes of our electric field module we will compute the electric field via Coulomb’s Law so we choose to name our module CoulombsLaw and we store the declaration in a file modules.hpp. The relevant lines are:

#pragma once
#include <pluginplay/pluginplay.hpp>

namespace pluginplay_examples {

DECLARE_MODULE(CoulombsLaw);

} // namespace pluginplay_examples

Note

Most libraries include more than one module, and given that declaring a module requires a single line, it is not uncommon to declare multiple modules in a single header file.

Note

Declaring a module is not required if the module is written in Python.

Defining the Module

The definition of our CoulombsLaw module goes in a source file. Defining the module amounts to implementing two functions: the default constructor and the run_ member function. The constructor is where all the set-up for your module occurs; it is also where you can set meta-data associated with your module. The run_ member function is where your module does the work.

Defining the Constructor

PluginPlay provides the macro MODULE_CTOR(T), where T is the name of the module to aid in defining the module’s constructor. At a minimum the constructor must set the property type(s) that the module satisfies. It is also a good idea to provide a short description about the algorithm implemented in the module.

Hint

The property type will be needed in a few places so it is a good idea to set a typedef, this way if you need to change the property type for any reason you only need to change it in the typedef.

Hint

The raw string literal introduced in C++11 denoted by auto str = R"(...)"; is a great way to write a lengthy description without having to use escapes or other workarounds for line endings.

For our CoulombsLaw module a basic constructor looks like:

static constexpr auto module_desc = R"(
Electric Field From Coulomb's Law
---------------------------------

This module computes the electric field of a series of point charges using
Coulomb's law according to:

.. math::

   \vec{E}(\vec{r}) = \sum_{i=1}^N
                      \frac{q_i \hat{r}_i}{||\vec{r} - \vec{r}_i||^2}
)";

MODULE_CTOR(CoulombsLaw) {
    description(module_desc);
    satisfies_property_type<ElectricField>();
}

One particularly nice feature of PluginPlay is that it can autogenerate restructured text documentation for modules by scraping the meta-data and fields of the module. The description we provided serves as a preface to the resulting page. Some of the other meta-data that module developers can set in the constructor includes: a list of citations, the author of the module, and the version. A full list of meta-data is available (TODO: Add link).

Note

PluginPlay automatically documents input parameters and submodules so there is no need to include these in your description.

For some modules, inputs that are not defined by the property type(s) may be needed. Additional inputs can be specified in the constructor with the add_inputs method. A similar option for added results exist, though it is less common. Additional inputs have to be set before the module is run, either with a default value or by calling the change_input method. Here are examples for adding a screening threshold to our ScreenedCoulombsLaw module:

MODULE_CTOR(ScreenedCoulombsLaw) {
    description(module_desc);
    satisfies_property_type<prop_type>();

    add_input<double>("threshold")
      .set_description("maximum ||r-r_i|| for contributing to electric field")
      .set_default(std::numeric_limits<double>::max());
}

Defining the Run Member

The run_ member of the module takes a set of inputs and a set of submodules, and returns the property (or properties). Like the module constructor, PluginPlay provides the macro MODULE_RUN(T), where T is the name of the module to aid in defining the module’s run_ member function. By convention (and hidden in the definition of the macro) the names of the two arguments are: inputs and submods. Presently we will ignore the submods argument.

For defining our CoulombsLaw::run_ member the relevant PluginPlay bits of code are:

MODULE_RUN(CoulombsLaw) {
    const auto& [r, charges] = ElectricField::unwrap_inputs(inputs);

    // This will be the value of the electric field
    Point E{0.0, 0.0, 0.0};
    auto rv = results();
    return ElectricField::wrap_results(rv, E);
}

The inputs to a module are type-erased. Getting them back in a usable typed form can be done automatically via template meta-programming. The details of this process are beyond our present scope; for now know that every property type defines a static function unwrap_inputs which takes the type-erased inputs and returns an std::tuple with the typed inputs (in the above example we use C++17’s structured bindings to automatically unpack the tuple). In a similar vein the results of every module are also type-erased and every property type defines a static function wrap_results which takes the typed results and returns a result map with the type-erased results.

Note

If a module has additional inputs beyond those specified by the property type, they will have to be specifically unpacked from the inputs. In the case of the ScreenedCoulombsLaw module, the screening threshold is acquired as:

MODULE_RUN(ScreenedCoulombsLaw) {
    const auto& [r, charges] = prop_type::unwrap_inputs(inputs);
    auto thresh              = inputs.at("threshold").value<double>();

The full definition of the run_ member for the CoulombsLaw module (including the source code for computing the electric field) is:

MODULE_RUN(CoulombsLaw) {
    const auto& [r, charges] = ElectricField::unwrap_inputs(inputs);

    // This will be the value of the electric field
    Point E{0.0, 0.0, 0.0};

    // This loop fills in E
    for(const auto& charge : charges) {
        auto q   = charge.m_charge;
        auto& ri = charge.m_r;

        // Magnitude of r_i
        auto ri2    = std::inner_product(ri.begin(), ri.end(), ri.begin(), 0.0);
        auto mag_ri = std::sqrt(ri2);

        // ||r - r_i||**2
        Point rij(r);
        for(std::size_t i = 0; i < 3; ++i) rij[i] -= charge.m_r[i];
        auto rij2 =
          std::inner_product(rij.begin(), rij.end(), rij.begin(), 0.0);

        for(std::size_t i = 0; i < 3; ++i) E[i] += q * ri[i] / (mag_ri * rij2);
    }

    auto rv = results();
    return ElectricField::wrap_results(rv, E);
}

Submodules

Coulomb’s Law is pretty simple. Most simulation techniques are much more complicated than that and involve an intricate interplay of many different properties.

As an example say we are writing a module which computes the force of a moving charged particle at a position \(\vec{r}\), \(\vec{F}\left(\vec{r}\right)\). Assuming the particle has a mass \(m\), its acceleration at \(\vec{r}\) is given by \(\vec{a}\left(\vec{r}\right)\), and that it has a charge \(q\), the \(\vec{F}\left(\vec{r}\right)\) is given by:

\[ \begin{align}\begin{aligned}\newcommand{\force}{\vec{F}\left(\vec{r}\right)} \newcommand{\acceleration}{\vec{a}\left(\vec{r}\right)} \newcommand{\efield}{\vec{E}\left(\vec{r}\right)}\\\force = m\acceleration + q\efield\end{aligned}\end{align} \]

The important point for this tutorial is that \(F\left(\vec{r}\right)\) can be written in terms of the electric field, \(\vec{E}\left(\vec{r}\right)\). To capitialize on all the innovations in computing electric fields, we decide to write our force module in terms of an electric field submodule.

Submodules change the class’s definition. In the constructor we now need to declare that our module uses a submodule and that the submodule must satisfy the property type ElectricField. This looks like:

MODULE_CTOR(ClassicalForce) {
    description(module_desc);
    satisfies_property_type<force_type>();

    add_submodule<efield_type>("electric field")
      .set_description(
        "Used to compute the electric field of the point charges");
}

PluginPlay also requires that we specify a tag (we choose the tag "electric field"). The tag ensures that the module can specify and distinguish between multiple submodules of the same type. When appropriately named, the tag also aids in readability of the code. The run_ function for our module looks like:

MODULE_RUN(ClassicalForce) {
    const auto& [q, m, a, charges] = force_type::unwrap_inputs(inputs);

    // This will be the value of the force
    Point F{0.0, 0.0, 0.0};

    auto E = submods.at("electric field").run_as<efield_type>(q.m_r, charges);

    for(std::size_t i = 0; i < 3; ++i) { F[i] = m * a[i] + q.m_charge * E[i]; }

    auto rv = results();
    return force_type::wrap_results(rv, F);
}

Of note is the line:

    auto E = submods.at("electric field").run_as<efield_type>(q.m_r, charges);

which says we are running the submodule tagged with "electric field" as an efield_type. We provide the submodule with the point charge’s location and the list of point charges defining the electric field.

Submodules are integral to PluginPlay so it is worth noting:

  • By using submodules we establish data dependencies (i.e. here we stated we need an electric field, we don’t care how it’s computed)

  • If tomorrow someone writes a new electric field module, it can immediately be used with our force module without modifying the force module.

  • The submodule that is called can be changed at runtime by the user and our module doesn’t have to maintain the logic to do so (i.e. our force module doesn’t need to maintain a list of all possible submodules and switch between them).

  • If the calculation crashes after computing the electric field, but before the module completes (pretty unlikely given how few operations remain) memoization will avoid the need to recompute the electric field when the calculation is restarted. The module does not have to maintain separate checkpoint/restart logic!!!!!