Repository: openmc-dev/openmc

Issue:
Add support for deserialization of custom source
The current implementation of a custom source is supported by the user providing a compiled shared object library, which implements the `sample_source` function, having a template providing an array of random seeds and returning a `Particle::Bank`. This is convenient for static sources, where the parametrisation of that source does not change but is less convenient is the source can vary e.g. between sequential runs. In this case the current solution would require the user to recompile their custom source with the new parametrisation and use the new library in the new run.

This Issue suggests to also attempt to dynamically load a `sample_source` function with an additional parameter that accepts a path to a file containing a serialised representation of the parameterised source. The existing function template would still be loadable, to avoid regression effects. This would allow the source to be deserialized before sampling, allowing for a more dynamic approach that does not require recompilation. Docs and tests relating to the custom source would also need to be updated to describe this alternative approach.

I'd be happy to put a PR together for this (assuming there is some availability to review that), but wanted to throw the issue out there first in case there are any comments or alternative suggestions before I get started.


Discussion:
Of course the name of the new function would have to be different from `sample_source` to allow it to be a c-style function and avoid name mangling. I'd suggest something like `deserialize_sample_source`.
Why not just pass the parametrisation through the input xml (or equivalently through the serialised xml form the python interface)?

On 28 Jul 2020, at 19:50, DanShort12 <notifications@github.com<mailto:notifications@github.com>> wrote:



Of course the name of the new function would have to be different from sample_source to allow it to be a c-style function and avoid name mangling. I'd suggest something like deserialize_sample_source.

—
You are receiving this because you are subscribed to this thread.
Reply to this email directly, view it on GitHub<https://github.com/openmc-dev/openmc/issues/1621#issuecomment-665214797>, or unsubscribe<https://github.com/notifications/unsubscribe-auth/AASTUSXGBHTOWV3Q6TLDS33R54MYNANCNFSM4PKZUUQQ>.


Fair point, I was considering a couple of factors:

1.  A general user might not want to serialise/deserialise to/from XML, in which case they'd have to embed their favoured format within the settings.xml, with appropriate escaping.
2.  If the serialisation is passed into settings.xml as XML then the schema of the serialised source and the settings.xml get merged, so again unless the serialised representation is escaped there may be some unexpected interactions.

That being said, from the perspective of OpenMC the proposed new element in the settings.xml would have a string-like value so a user could in principle use whatever string they like, whether that be a file path of a direct injection of the serialised source. Perhaps a compromise would be to give that element a name that is not so specific (`serialised_source` rather than `serialised_source_path`, for example), then the implementation in OpenMC is agnostic to how the user wants to perform their serialisation/deserialisation routines?

Resolving patch:
diff --git a/examples/parameterized_custom_source/build_xml.py b/examples/parameterized_custom_source/build_xml.py
new file mode 100644
--- /dev/null
+++ b/examples/parameterized_custom_source/build_xml.py
@@ -0,0 +1,37 @@
+import openmc
+
+# Create a single material
+iron = openmc.Material()
+iron.set_density('g/cm3', 5.0)
+iron.add_element('Fe', 1.0)
+mats = openmc.Materials([iron])
+mats.export_to_xml()
+
+# Create a 5 cm x 5 cm box filled with iron
+box = openmc.model.rectangular_prism(10.0, 10.0, boundary_type='vacuum')
+cell = openmc.Cell(fill=iron, region=box)
+geometry = openmc.Geometry([cell])
+geometry.export_to_xml()
+
+# Tell OpenMC we're going to use our custom source
+settings = openmc.Settings()
+settings.run_mode = 'fixed source'
+settings.batches = 10
+settings.particles = 1000
+source = openmc.Source()
+source.library = 'build/libparameterized_source.so'
+source.parameters = 'radius=3.0, energy=14.08e6'
+settings.source = source
+settings.export_to_xml()
+
+# Finally, define a mesh tally so that we can see the resulting flux
+mesh = openmc.RegularMesh()
+mesh.lower_left = (-5.0, -5.0)
+mesh.upper_right = (5.0, 5.0)
+mesh.dimension = (50, 50)
+
+tally = openmc.Tally()
+tally.filters = [openmc.MeshFilter(mesh)]
+tally.scores = ['flux']
+tallies = openmc.Tallies([tally])
+tallies.export_to_xml()
diff --git a/examples/parameterized_custom_source/show_flux.py b/examples/parameterized_custom_source/show_flux.py
new file mode 100644
--- /dev/null
+++ b/examples/parameterized_custom_source/show_flux.py
@@ -0,0 +1,14 @@
+import matplotlib.pyplot as plt
+import openmc
+
+# Get the flux from the statepoint
+with openmc.StatePoint('statepoint.10.h5') as sp:
+    flux = sp.tallies[1].mean
+    flux.shape = (50, 50)
+
+# Plot the flux
+fig, ax = plt.subplots()
+ax.imshow(flux, origin='lower', extent=(-5.0, 5.0, -5.0, 5.0))
+ax.set_xlabel('x [cm]')
+ax.set_ylabel('y [cm]')
+plt.show()
diff --git a/openmc/source.py b/openmc/source.py
--- a/openmc/source.py
+++ b/openmc/source.py
@@ -22,6 +22,8 @@ class Source:
         Source file from which sites should be sampled
     library : str
         Path to a custom source library
+    parameters : str
+        Parameters to be provided to the custom source library
 
         .. versionadded:: 0.12
     strength : float
@@ -41,6 +43,8 @@ class Source:
         Source file from which sites should be sampled
     library : str or None
         Path to a custom source library
+    parameters : str
+        Parameters to be provided to the custom source library
     strength : float
         Strength of the source
     particle : {'neutron', 'photon'}
@@ -49,12 +53,13 @@ class Source:
     """
 
     def __init__(self, space=None, angle=None, energy=None, filename=None,
-                 library=None, strength=1.0, particle='neutron'):
+                 library=None, parameters=None, strength=1.0, particle='neutron'):
         self._space = None
         self._angle = None
         self._energy = None
         self._file = None
         self._library = None
+        self._parameters = None
 
         if space is not None:
             self.space = space
@@ -66,6 +71,8 @@ def __init__(self, space=None, angle=None, energy=None, filename=None,
             self.file = filename
         if library is not None:
             self.library = library
+        if parameters is not None:
+            self.parameters = parameters
         self.strength = strength
         self.particle = particle
 
@@ -77,6 +84,10 @@ def file(self):
     def library(self):
         return self._library
 
+    @property
+    def parameters(self):
+        return self._parameters
+
     @property
     def space(self):
         return self._space
@@ -107,6 +118,11 @@ def library(self, library_name):
         cv.check_type('library', library_name, str)
         self._library = library_name
 
+    @parameters.setter
+    def parameters(self, parameters_path):
+        cv.check_type('parameters', parameters_path, str)
+        self._parameters = parameters_path
+
     @space.setter
     def space(self, space):
         cv.check_type('spatial distribution', space, Spatial)
@@ -150,6 +166,8 @@ def to_xml_element(self):
             element.set("file", self.file)
         if self.library is not None:
             element.set("library", self.library)
+        if self.parameters is not None:
+            element.set("parameters", self.parameters)
         if self.space is not None:
             element.append(self.space.to_xml_element())
         if self.angle is not None:
@@ -191,6 +209,10 @@ def from_xml_element(cls, elem):
         if library is not None:
             source.library = library
 
+        parameters = get_text(elem, 'parameters')
+        if parameters is not None:
+            source.parameters = parameters
+
         space = elem.find('space')
         if space is not None:
             source.space = Spatial.from_xml_element(space)


Test patch:
diff --git a/tests/regression_tests/source_dlopen/source_sampling.cpp b/tests/regression_tests/source_dlopen/source_sampling.cpp
--- a/tests/regression_tests/source_dlopen/source_sampling.cpp
+++ b/tests/regression_tests/source_dlopen/source_sampling.cpp
@@ -1,11 +1,14 @@
 #include <iostream>
+#include <memory>
+
 #include "openmc/random_lcg.h"
 #include "openmc/source.h"
 #include "openmc/particle.h"
 
-// you must have external C linkage here otherwise 
-// dlopen will not find the file
-extern "C" openmc::Particle::Bank sample_source(uint64_t *seed) {
+class Source : openmc::CustomSource
+{
+  openmc::Particle::Bank sample(uint64_t *seed)
+  {
     openmc::Particle::Bank particle;
     // wgt
     particle.particle = openmc::Particle::Type::neutron;
@@ -20,4 +23,13 @@ extern "C" openmc::Particle::Bank sample_source(uint64_t *seed) {
     particle.E = 14.08e6;
     particle.delayed_group = 0;
     return particle;    
+  }
+};
+
+// A function to create a unique pointer to an instance of this class when generated
+// via a plugin call using dlopen/dlsym.
+// You must have external C linkage here otherwise dlopen will not find the file
+extern "C" std::unique_ptr<Source> openmc_create_source(std::string parameters)
+{
+  return std::make_unique<Source>();
 }
diff --git a/tests/regression_tests/source_parameterized_dlopen/__init__.py b/tests/regression_tests/source_parameterized_dlopen/__init__.py
new file mode 100644
diff --git a/tests/regression_tests/source_parameterized_dlopen/inputs_true.dat b/tests/regression_tests/source_parameterized_dlopen/inputs_true.dat
new file mode 100644
--- /dev/null
+++ b/tests/regression_tests/source_parameterized_dlopen/inputs_true.dat
@@ -0,0 +1,23 @@
+<?xml version='1.0' encoding='utf-8'?>
+<geometry>
+  <cell id="1" material="1" region="-1" universe="1" />
+  <surface boundary="vacuum" coeffs="0.0 0.0 0.0 100" id="1" type="sphere" />
+</geometry>
+<?xml version='1.0' encoding='utf-8'?>
+<materials>
+  <material id="1" name="natural_lead">
+    <density units="g/cm3" value="11.34" />
+    <nuclide ao="0.014" name="Pb204" />
+    <nuclide ao="0.241" name="Pb206" />
+    <nuclide ao="0.221" name="Pb207" />
+    <nuclide ao="0.524" name="Pb208" />
+  </material>
+</materials>
+<?xml version='1.0' encoding='utf-8'?>
+<settings>
+  <run_mode>fixed source</run_mode>
+  <particles>1000</particles>
+  <batches>10</batches>
+  <inactive>0</inactive>
+  <source library="build/libsource.so" parameters="1e3" strength="1.0" />
+</settings>
diff --git a/tests/regression_tests/source_parameterized_dlopen/parameterized_source_sampling.cpp b/tests/regression_tests/source_parameterized_dlopen/parameterized_source_sampling.cpp
new file mode 100644
--- /dev/null
+++ b/tests/regression_tests/source_parameterized_dlopen/parameterized_source_sampling.cpp
@@ -0,0 +1,38 @@
+#include "openmc/source.h"
+#include "openmc/particle.h"
+
+class Source : public openmc::CustomSource {
+  public:
+    Source(double energy) : energy_(energy) { }
+
+    // Samples from an instance of this class.
+    openmc::Particle::Bank sample(uint64_t* seed)
+    {
+      openmc::Particle::Bank particle;
+      // wgt
+      particle.particle = openmc::Particle::Type::neutron;
+      particle.wgt = 1.0;
+      // position
+      particle.r.x = 0.0;
+      particle.r.y = 0.0;
+      particle.r.z = 0.0;
+      // angle
+      particle.u = {1.0, 0.0, 0.0};
+      particle.E = this->energy_;
+      particle.delayed_group = 0;
+
+      return particle;
+    }
+
+  private:
+    double energy_;
+};
+
+// A function to create a unique pointer to an instance of this class when generated
+// via a plugin call using dlopen/dlsym.
+// You must have external C linkage here otherwise dlopen will not find the file
+extern "C" std::unique_ptr<Source> openmc_create_source(std::string parameter)
+{
+  double energy = std::stod(parameter);
+  return std::make_unique<Source>(energy);
+}
diff --git a/tests/regression_tests/source_parameterized_dlopen/results_true.dat b/tests/regression_tests/source_parameterized_dlopen/results_true.dat
new file mode 100644
diff --git a/tests/regression_tests/source_parameterized_dlopen/test.py b/tests/regression_tests/source_parameterized_dlopen/test.py
new file mode 100644
--- /dev/null
+++ b/tests/regression_tests/source_parameterized_dlopen/test.py
@@ -0,0 +1,75 @@
+from pathlib import Path
+import os
+import shutil
+import subprocess
+import textwrap
+
+import openmc
+import pytest
+
+from tests.testing_harness import PyAPITestHarness
+
+
+@pytest.fixture
+def compile_source(request):
+    """Compile the external source"""
+
+    # Get build directory and write CMakeLists.txt file
+    openmc_dir = Path(str(request.config.rootdir)) / 'build'
+    with open('CMakeLists.txt', 'w') as f:
+        f.write(textwrap.dedent("""
+            cmake_minimum_required(VERSION 3.3 FATAL_ERROR)
+            project(openmc_sources CXX)
+            add_library(source SHARED parameterized_source_sampling.cpp)
+            find_package(OpenMC REQUIRED HINTS {})
+            target_link_libraries(source OpenMC::libopenmc)
+            """.format(openmc_dir)))
+
+    # Create temporary build directory and change to there
+    local_builddir = Path('build')
+    local_builddir.mkdir(exist_ok=True)
+    os.chdir(str(local_builddir))
+
+    # Run cmake/make to build the shared libary
+    subprocess.run(['cmake', os.path.pardir], check=True)
+    subprocess.run(['make'], check=True)
+    os.chdir(os.path.pardir)
+
+    yield
+
+    # Remove local build directory when test is complete
+    shutil.rmtree('build')
+    os.remove('CMakeLists.txt')
+
+
+@pytest.fixture
+def model():
+    model = openmc.model.Model()
+    natural_lead = openmc.Material(name="natural_lead")
+    natural_lead.add_element('Pb', 1.0)
+    natural_lead.set_density('g/cm3', 11.34)
+    model.materials.append(natural_lead)
+
+    # geometry
+    surface_sph1 = openmc.Sphere(r=100, boundary_type='vacuum')
+    cell_1 = openmc.Cell(fill=natural_lead, region=-surface_sph1)
+    model.geometry = openmc.Geometry([cell_1])
+
+    # settings
+    model.settings.batches = 10
+    model.settings.inactive = 0
+    model.settings.particles = 1000
+    model.settings.run_mode = 'fixed source'
+
+    # custom source from shared library
+    source = openmc.Source()
+    source.library = 'build/libsource.so'
+    source.parameters = '1e3'
+    model.settings.source = source
+
+    return model
+
+
+def test_dlopen_source(compile_source, model):
+    harness = PyAPITestHarness('statepoint.10.h5', model)
+    harness.main()