PLaSK library
|
Solvers in PLaSK are calculations units. Each is represented by subclass of plask::Solver.
Each solver has one or more input and one or more output realized by providers and receivers mechanism. This allows for communication between solvers.
Typically, each solver is connected with geometry object which describe physical property (mainly materials) of laser or its fragment. This geometry object defines (local) calculation space for solver.
Typically, each solver also includes mesh which represent set of point (in calculation space) in which solver calculate its result. If another solver requests for data in points other than these includes in mesh, result can be interpolated.
TODO
Note that typically, solvers are used from python scripts.
To write solver you should:
Once you have your source tree set up, do the following:
solvers/skel/python/solver.cpp
(for your convenience we have provided some macros that will facilitate creation of Python interface).solvers.yml
file that documents xpl
configuration of your solver for generating the xpl
reference in the user manual and for automatic configuration panel creation in GUI.Below we explain the above steps in detail on a simple example. When following this tutorial work already on your own solver, or write a sample code in a separate directory in order to keep the PLaSK source tree (and repository) clean.
Assume that we want to write a solver computing a waveguide effective index and optical field intensity for edge emitting lasers. The solver performs its computation using finite differences method. Hence, we name it FiniteDifferencesSolver.
To start we create a subdirectory solvers/optical/finite_diff under the PLaSK trunk write a file finite_differences.h to it. Also we need to copy the CMakeLists.txt file from solvers/skel to our directory. In most cases we will only need to edit the line with the command project
in this file, unless our solvers uses some external libraries. If for example, our solver uses LAPACK, the CMakeLists.txt should look like this (In the below example all comments are skipped):
The project name should math the pattern plask/solvergroup/solverlib, so in our case it will look like plask/optical/finite_diff.
project(plask/optical/finite_diff) set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/../../../cmake) find_package(PLaSK) find_package(LAPACK) set(SOLVER_LINK_LIBRARIES ${LAPACK_LIBRARIES}) make_default()
If you are working within the PLaSK source, remember to add all the directories and files to the subversion repository (only for your real solver naturally)!
Now we assume that our solver uses rectilinear mesh provided by the user, so we inherit our class from plask::SolverWithMesh<plask::Geometry2DCartesian, plask::RectangularMesh<2>>. So our header finite_differences.h should begin as follows:
(note that PLASK_SOLVER_API is required for proper exporting/importing the solver to/from DLL file)
Then, you declare all the fields and methods of the class. We will skip all the fields that are required privately for computations from this tutorial and focus only on the ones necessary for PLaSK interface. Assume, that FiniteDifferencesSolver needs a temperature distribution and wavelength as an input, and outputs effective index of the waveguide and the optical field intensity. Additionally, boundary conditions of the first kind on temperature is needed. Hence, we declare the following providers, receivers and boundary conditions:
In the code above, we have declared two receivers (by convention in PLaSK, names of every receiver in all solvers should begin with in prefix). inWavelength
can receive a single value either from some connected solver (e.g. a computed gain maximum) or specified by the user. On the other hand inTemperature
receives temperature distribution, which is a scalar field. For this reason it is necessary to specify the type of the space in which this distribution will be computed. Naturally it should match the working space of your solver.
When declaring providers, one needs to specify how the provided value can be obtained. In our case outNeff
(again, the names of every provider in all solvers should begin with out prefix) will hold its value internally i.e. you will be able to assign a value to it as simply as outNeff = 3.5;
. On the other hand, outLightMagnitude
is a delegate provider i.e. you will need to write a method which computes the light intensity on demand (we will later show you how).
As your solver inherits plask::SolverWithMesh, there is already a shared pointer to the geometry
and mesh
available. However, it might be a good idea to create the class member field for the light intensity computed on the solver mesh to be able to efficiently provide it to other solvers. You can use any type for array for this (e.g. std::vector<double>), but—as the solvers exchange field data using plask::DataVector class—it is best to use this class for internal storage as well (it behaves mostly like std::vector, with some additional improvements required for shared memory management between the solvers). So we declare the private field:
Now, you can write the constructor to your class. By convention this constructor should take no configuration arguments as all the solver configuration parameters must be able to be set by the user afterwards (in future you might want to create a configuration window for your solver for GUI). The only constructor parameter is the name, which can be provided by user, and which should be passed to the parent class. In addition, you should write getClassName method, which returns the category and the name of your solver class as seen by the end user (it does not need to be the same as your real class name, but it should match the Python class name and solver name in XML file).
In the above illustration, we initialize the outLightMagnitude
provider with the pointer to the solver itself and the address of the method computing the light intensity (we write this method later). Also, we set the default value of the temperature to 300 K in the whole structure. As there is not default value for inWavelength, the user will have to provide it (either manually or from some wavelength provider) or the exception will be raised when we try to retrieve the wavelength value in our computation method.
Before we write a computation method (or several computation methods), we must realize that the solver can be in two states: initialized or invalidated. When the user creates the solver object, sets the geometry and the mesh, there is still no memory allocated for our results (i.e. computed_light_intensity
is an empty vector) nor the results are know. This is called an invalidated state. When in this state, the first step before any calculation is to allocate the memory: resize the computed_light_intensity
to the proper size, allocate some internal matrices (not mentioned in this tutorial), etc. The size of the allocated memory depends e.g. on the mesh size, so it cannot be done in the constructor. For this reason, in the beginning of every calculation function you should call the method initCalculation(), which is defined in plask::Solver base class. If the solver has been in invalidated state, this it will call virtual method onInitialize(), in which you can put all your initialization code. Then the solver will be put in initialized state (hence, subsequent calls to computational method will not call onInitialize()
unless solver is forcefully invalidated (see below). The code on the initialization method may look like this:
Even after some computations have been performed, the user might change the geometry of the structure or the mesh. In such case the results of your computations becomes outdated and the sizes of some matrices might need to change. In such situation, the solver is put back to invalidated state and, similarly to the initialization, the virtual method onInvalidate() is called. This method may look like this:
This method can be also forcefully called by the user issuing your_solver.invalidate();
command. This might be done in order to free them memory for some other computations. For this reason you should free all large chunks of memory in onInvalidate()
. However, If you store your data in an array with no built-in memory management (e.g. old-style C-arrays), you have to check if the memory has been previously allocated, as onInvalidate()
might be called before onInitialize()
. Furthermore you should call this method from your class destructor in such case.
After the solver has been invalidated, before the next computations onInitialize()
will be called, so the new memory will be allocated as needed.
Now you can write your core computational methods. Just remember to call initCalculation() in the beginning. Furthermore, if the provided values change (and they probably do), you must call method fireChanged() for each of your providers, to notify the connected receivers about the change. Here is the sample code:
Assume that in the above sample computation method, we did not compute the light intensity. We will do it only if someone wants to know it. Of course the choice when to compute what depends strongly on your particular solver and you should focus on efficiency (i.e. compute only what is necessary) when making decisions on this matter.
The last thing to do is to write the method called by the delegate provider outLightMagnitude
when someone wants to get the optical field intensity distribution. The arguments and return value of this method depend on the provider type. For interpolated fields they will look like in the following example:
The important objects of the above method are the first and the last lines. In the former one, we check if the computations have been performed and are up-to-date (remember, we have cleared the value of outNeff
in onInvalidate()
). Otherwise we throw an exception. In the last line we use plask::interpolate function to interpolate our data to the receiver mesh (which is provided as destination_mesh
argument).
Passing this->geometry helps to automatically consider mirror and periodic boundaries, so the requested points will be wrapped into your computational domain correctly. And getInterpolationMethod changes INTERPOLATION_DEFAULT method to some real one.
Our solver can perform computations now. However, if it has any configuration to load, we can read it from XML file. To do this, we should reimplement loadConfiguration
method. It reads the configuration from the current XML file using plask::XMLReader, by walking through the consecutive tags. It is important to call parseStandardConfiguration
if you encounter unknown tag. Below you have an example:
In the above example we assume that we have some local struct
with parameters of Newton algorithm: tolx
, tolf
, and maxstep
. They are read from the corresponding attributes of a <newton> tag, with default value equal to the current value of the corresponding parameter. Furthermore, user can optionally specify a wavelength, which we set as a specified input value of the inWavelength receiver (single-value receivers can be connected to providers, however they can also have assigned value as normal variables, although in any case to read their values you must remember about using parenthesis, e.g. w = inWavelength()
).
The XML file to read by the above method can look as follows (although you should rather use XML attributes to set simple parameters, in order to make the XML file consistent for all the solvers).:
<optical lib="finite_diff" solver="FiniteDifferences2D"> <newton tolx="0.0001" tolf="1e-9" maxstep="500" /> <wavelenght>1000</wavelength> </optical>
You can now finish your class definition:
Once you have written all the C++ code of your solver, you should export it to the Python interface. To do this, create a subdirectory named python in your solver tree (i.e. solvers/optical/finite_diff/python) and copy your_solver.cpp from solvers/skel/python there (changing its name e.g. to finite_differences_python.cpp).
Then edit the file to export all necessary methods and public fields. The contents of this file should look more less like this:
BOOST_PYTHON_MODULE
macro takes the name of the package with your solver (without quotation marks). Your solver will be accessible to Python if the user imports this package as:
The arguments of the CLASS
macro are your solver class, the name in which it will appear in Python, and short solver documentation (mind the braces outside of the CLASS:
they are important if you want to put more than one solver in a single interface file, so they will appear in a single package).
Next you define your exported class member fields, properties (fake fields, which call your class methods on reading or assignment), methods, providers, and receivers (you have probably noticed that providers and receivers are just class member fields, but they need to be exported using separate macros, due to some additional logic necessary). Below, there is a complete list of macros exporting class objects and we believe it is self-explanatory:
When defining methods that take some arguments, you need to put their names after the documentation, so the user can set them by name, as for typical Python methods. You can also specify some default values of some arguments as shown above.
After you successfully compile the solver and the Python interface (just run cmake
and make
), the user should be able to execute the following Python code:
TODO
Once your solver is working, you should create the solvers.yml file in the main directory of your solver. In this file you should specify all the XML configuration tags and attributes that are read from an xpl
file. An example of such file can be found in solvers/skel/solvers.yml
. For the above example the file should look like:
This concludes our short tutorial. Now you can go on and write your own calculation solver. Good luck!