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>();
}
def __init__(self):
pp.ModuleBase.__init__(self)
self.description("Electric Field From Coulomb's Law")
self.satisfies_property_type(ppe.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());
}
def __init__(self):
pp.ModuleBase.__init__(self)
self.description("Screened Electric Field From Coulomb's Law")
self.satisfies_property_type(ppe.ElectricField())
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);
}
def run_(self, inputs, submods):
pt = ppe.ElectricField()
[r, charges] = pt.unwrap_inputs(inputs)
E = [0.0, 0.0, 0.0]
rv = self.results()
return pt.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>();
def run_(self, inputs, submods):
pt = ppe.ElectricField()
[r, charges] = pt.unwrap_inputs(inputs)
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);
}
def run_(self, inputs, submods):
pt = ppe.ElectricField()
[r, charges] = pt.unwrap_inputs(inputs)
E = [0.0, 0.0, 0.0]
for charge in charges:
q = charge.m_charge
ri = charge.m_r
ri2 = ri[0]**2 + ri[1]**2 + ri[2]**2
mag_ri = sqrt(ri2)
rij = [i for i in r]
for i in range(3):
rij[i] -= charge.m_r[i]
rij2 = rij[0]**2 + rij[1]**2 + rij[2]**2
for i in range(3):
E[i] += (q * ri[i] / (mag_ri * rij2))
rv = self.results()
return pt.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:
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!!!!!