From da5d9daeae448a6375c01719a3e33f4f9b17f8a8 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Wed, 20 May 2026 16:51:27 -0600 Subject: [PATCH 01/22] First commit --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 6539ec3c7c5..6750c39779c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +# For Implicit Function development +This branch aims to develop a framework to create any surface defined by an implicit function. A dedicated solver and architecture is proposed. + # OpenMC Monte Carlo Particle Transport Code [![License](https://img.shields.io/badge/license-MIT-green)](https://docs.openmc.org/en/latest/license.html) From feeafe4f4382f5b5df9b23d00195ad396437c2c4 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Fri, 22 May 2026 18:32:06 -0600 Subject: [PATCH 02/22] ImplicitSurface, TPMS and ImplicitFunction implementation. --- openmc/implicit.py | 339 ++++++++++++++++++++++++++++++ openmc/surface.py | 191 +++++++++++++++++ tests/unit_tests/test_implicit.py | 244 +++++++++++++++++++++ 3 files changed, 774 insertions(+) create mode 100644 openmc/implicit.py create mode 100644 tests/unit_tests/test_implicit.py diff --git a/openmc/implicit.py b/openmc/implicit.py new file mode 100644 index 00000000000..9e76f28d294 --- /dev/null +++ b/openmc/implicit.py @@ -0,0 +1,339 @@ +from __future__ import annotations +from abc import ABC, abstractmethod + +import lxml.etree as ET +import numpy as np + +# ------------------------------------------------------------------ +# Helper functions +# ------------------------------------------------------------------ + +def _to_function(f: int | float | ImplicitFunction) -> ImplicitFunction: + return Constant(float(f)) if isinstance(f, (int, float)) else f + +# ------------------------------------------------------------------ +# Main ImplicitFunction Abstract class +# ------------------------------------------------------------------ + +class ImplicitFunction(ABC): + + @abstractmethod + def __repr__(self) -> str: ... + + @abstractmethod + def evaluate(self, point) -> float: ... + + @abstractmethod + def to_xml_element(self, _cached: list[int] | None = None) -> ET.Element: ... + + @classmethod + def from_xml_element(cls, element: ET.Element, _cached: dict | None = None) -> ImplicitFunction: + if _cached is None: _cached = {} + tag = element.tag + attrib = element.attrib + + # Handle cache reference before parsing children (no children to parse) + if tag == "from_cache": + return _cached[int(attrib["id"])] + + # Recursively parse children for all other tags + children = [cls.from_xml_element(child, _cached) for child in element] + + # Register a new cached node and return it + if tag == "to_cache": + node = Cached(children[0]) + _cached[int(attrib["id"])] = node + return node + + dispatch = { + "x": lambda: X(), + "y": lambda: Y(), + "z": lambda: Z(), + "constant": lambda: Constant(float(attrib["value"])), + "add": lambda: Add(*children), + "neg": lambda: Neg(*children), + "sub": lambda: Sub(*children), + "scale": lambda: Scale(children[0], float(attrib["value"])), + "mul": lambda: Mul(*children), + "div": lambda: Div(*children), + "pow": lambda: Pow(children[0], float(attrib["value"])), + "sin": lambda: Sin(*children), + "cos": lambda: Cos(*children), + "sqrt": lambda: Sqrt(*children), + "exp": lambda: Exp(*children), + "log": lambda: Log(*children), + "abs": lambda: Abs(*children), + } + + if tag not in dispatch: + raise ValueError(f"Unknown tag '{tag}'") + + return dispatch[tag]() + + # ------------------------------------------------------------------ + # Operator overloading — enables natural Python expression syntax + # e.g. Sin(X()) * Cos(Z()) + Constant(1) + # ------------------------------------------------------------------ + + def __add__(self, other: float | ImplicitFunction) -> ImplicitFunction: + return Add(_to_function(self), _to_function(other)) + + def __radd__(self, other: float | ImplicitFunction) -> ImplicitFunction: + return Add(_to_function(other), _to_function(self)) + + def __neg__(self) -> ImplicitFunction: + return Neg(_to_function(self)) + + def __sub__(self, other: float | ImplicitFunction) -> ImplicitFunction: + return Sub(_to_function(self), _to_function(other)) + + def __rsub__(self, other: float | ImplicitFunction) -> ImplicitFunction: + return Sub(_to_function(other), _to_function(self)) + + def __truediv__(self, other: float | ImplicitFunction) -> ImplicitFunction: + return Div(_to_function(self), _to_function(other)) + + def __rtruediv__(self, other: float | ImplicitFunction) -> ImplicitFunction: + return Div(_to_function(other), _to_function(self)) + + def __pow__(self, exp: int | float) -> ImplicitFunction: + if not isinstance(exp, (int, float)): + raise TypeError(f"Pow exponent must be a scalar, got {type(exp)}") + return Pow(_to_function(self), exp) + + def __mul__(self, other: float | ImplicitFunction) -> ImplicitFunction: + if isinstance(other, (int, float)): + return Scale(self, float(other)) + return Mul(self, other) + + def __rmul__(self, other: float | ImplicitFunction) -> ImplicitFunction: + if isinstance(other, (int, float)): + return Scale(self, float(other)) + return Mul(self, other) + +# --------------------------------------------------------------------------- +# Terminal nodes +# --------------------------------------------------------------------------- + +class X(ImplicitFunction): + def __repr__(self): return "X" + def evaluate(self, point): return point[0] + def to_xml_element(self, _cached=None): return ET.Element("x") + +class Y(ImplicitFunction): + def __repr__(self): return "Y" + def evaluate(self, point): return point[1] + def to_xml_element(self, _cached=None): return ET.Element("y") + +class Z(ImplicitFunction): + def __repr__(self): return "Z" + def evaluate(self, point): return point[2] + def to_xml_element(self, _cached=None): return ET.Element("z") + +class Constant(ImplicitFunction): + def __repr__(self): return f"{self.value}" + def __init__(self, value: float) -> None: + self.value = float(value) + def evaluate(self, point): return self.value + def to_xml_element(self, _cached=None): + element = ET.Element("constant") + element.set("value", str(self.value)) + return element + +# --------------------------------------------------------------------------- +# Operations +# --------------------------------------------------------------------------- + +class Add(ImplicitFunction): + + def __repr__(self): return f"{self.f} + {self.g}" + def __init__(self, f:ImplicitFunction, g:ImplicitFunction): + self.f = f + self.g = g + def evaluate(self, point): return self.f.evaluate(point) + self.g.evaluate(point) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("add") + element.append(self.f.to_xml_element(_cached)) + element.append(self.g.to_xml_element(_cached)) + return element + +class Neg(ImplicitFunction): + def __repr__(self): return f"-{self.f}" + def __init__(self, f:ImplicitFunction): + self.f = f + def evaluate(self, point): return -self.f.evaluate(point) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("neg") + element.append(self.f.to_xml_element(_cached)) + return element + +class Sub(ImplicitFunction): + def __repr__(self): return f"{self.f} - {self.g}" + def __init__(self, f:ImplicitFunction, g:ImplicitFunction): + self.f = f + self.g = g + def evaluate(self, point): return self.f.evaluate(point) - self.g.evaluate(point) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("sub") + element.append(self.f.to_xml_element(_cached)) + element.append(self.g.to_xml_element(_cached)) + return element + +class Scale(ImplicitFunction): + def __repr__(self): return f"{self.scalar} * {self.f}" + def __init__(self, f:ImplicitFunction, scalar: float): + self.f = f + self.scalar = float(scalar) + def evaluate(self, point): return self.scalar * self.f.evaluate(point) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("scale") + element.set("value", str(self.scalar)) + element.append(self.f.to_xml_element(_cached)) + return element + +class Mul(ImplicitFunction): + def __repr__(self): return f"{self.f} * {self.g}" + def __init__(self, f:ImplicitFunction, g:ImplicitFunction): + self.f = f + self.g = g + def evaluate(self, point): return self.f.evaluate(point) * self.g.evaluate(point) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("mul") + element.append(self.f.to_xml_element(_cached)) + element.append(self.g.to_xml_element(_cached)) + return element + +class Div(ImplicitFunction): + def __repr__(self): return f"{self.f} / {self.g}" + def __init__(self, f:ImplicitFunction, g:ImplicitFunction): + self.f = f + self.g = g + def evaluate(self, point): + v = self.g.evaluate(point) + if v == 0.: raise ValueError(f"Denominator value cannot be zero.") + return self.f.evaluate(point) / v + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("div") + element.append(self.f.to_xml_element(_cached)) + element.append(self.g.to_xml_element(_cached)) + return element + + +class Pow(ImplicitFunction): + def __repr__(self): return f"{self.f} ** {self.exp}" + def __init__(self, f:ImplicitFunction, exp: int | float): + self.f = f + self.exp = float(exp) + def evaluate(self, point): return self.f.evaluate(point) ** self.exp + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("pow") + element.set("value", str(self.exp)) + element.append(self.f.to_xml_element(_cached)) + return element + +# --------------------------------------------------------------------------- +# Functions +# --------------------------------------------------------------------------- + +class Sin(ImplicitFunction): + def __repr__(self): return f"Sin({self.arg})" + def __init__(self, arg:ImplicitFunction): + self.arg = arg + def evaluate(self, point): return np.sin(self.arg.evaluate(point)) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("sin") + element.append(self.arg.to_xml_element(_cached)) + return element + +class Cos(ImplicitFunction): + def __repr__(self): return f"Cos({self.arg})" + def __init__(self, arg:ImplicitFunction): + self.arg = arg + def evaluate(self, point): return np.cos(self.arg.evaluate(point)) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("cos") + element.append(self.arg.to_xml_element(_cached)) + return element + +class Sqrt(ImplicitFunction): + def __repr__(self): return f"Sqrt({self.arg})" + def __init__(self, arg:ImplicitFunction): + self.arg = arg + def evaluate(self, point): + v = self.arg.evaluate(point) + if v < 0.: raise ValueError(f"Sqrt input value cannot be negative but is: '{v}'.") + return np.sqrt(v) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("sqrt") + element.append(self.arg.to_xml_element(_cached)) + return element + +class Exp(ImplicitFunction): + def __repr__(self): return f"Exp({self.arg})" + def __init__(self, arg:ImplicitFunction): + self.arg = arg + def evaluate(self, point): return np.exp(self.arg.evaluate(point)) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("exp") + element.append(self.arg.to_xml_element(_cached)) + return element + +class Log(ImplicitFunction): + def __repr__(self): return f"Log({self.arg})" + def __init__(self, arg:ImplicitFunction): + self.arg = arg + def evaluate(self, point): + v = self.arg.evaluate(point) + if v < 0.: raise ValueError(f"Log input value cannot be negative but is: '{v}'.") + return np.log(v) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("log") + element.append(self.arg.to_xml_element(_cached)) + return element + +class Abs(ImplicitFunction): + def __repr__(self): return f"|{self.arg}|" + def __init__(self, arg:ImplicitFunction): + self.arg = arg + def evaluate(self, point): return np.abs(self.arg.evaluate(point)) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("abs") + element.append(self.arg.to_xml_element(_cached)) + return element + +# --------------------------------------------------------------------------- +# Cache +# --------------------------------------------------------------------------- + +class Cached(ImplicitFunction): + def __repr__(self): return f"@[{self.f}]" + def __init__(self, f:ImplicitFunction): + self.f = f + def evaluate(self, point): return self.f.evaluate(point) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + key = id(self) + if key in _cached: + node = ET.Element("from_cache") + node.set("id", str(_cached.index(key))) + return node + else: + _cached.append(key) + xml_id = len(_cached) - 1 + node = ET.Element("to_cache") + node.set("id", str(xml_id)) + node.append(self.f.to_xml_element(_cached)) + return node \ No newline at end of file diff --git a/openmc/surface.py b/openmc/surface.py index c2afeb613ac..b74958f1476 100644 --- a/openmc/surface.py +++ b/openmc/surface.py @@ -13,6 +13,8 @@ from .mixin import IDManagerMixin, IDWarning from .region import Region, Intersection, Union from .bounding_box import BoundingBox +from . import implicit +from .implicit import ImplicitFunction from ._xml import get_elem_list, get_text @@ -470,6 +472,10 @@ def from_xml_element(elem): coeffs = get_elem_list(elem, "coeffs", float) kwargs.update(dict(zip(cls._coeff_keys, coeffs))) + if surf_type == "implicit": + kwargs['func'] = ImplicitFunction.from_xml_element(elem.get("function")) + kwargs['isovalue'] = float(elem.get("isovalue")) + return cls(**kwargs) @staticmethod @@ -2582,6 +2588,189 @@ def bounding_box(self, side): elif side == '+': return BoundingBox.infinite() +class ImplicitSurface(Surface): + + _type = 'implicit' + _coeff_keys = ('x0', 'y0', 'z0', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i') + + def __init__(self, function:ImplicitFunction, isovalue:float=0., x0=0., y0=0., z0=0., a=1., b=0., c=0., d=0., e=1., f=0., g=0., h=0., i=1., **kwargs): + # Create the surface + super().__init__(**kwargs) + for key, val in zip(self._coeff_keys, (x0, y0, z0, a, b, c, d, e, f, g, h, i)): + setattr(self, key, val) + self.function = function + self.isovalue = float(isovalue) + # Check the implicit surface + if not self.boundary_type == "transmission": + raise ValueError(f"ImplicitSurface boundary type must be 'transmission' but is '{self.boundary_type}'.") + if not self._is_valid_rotation(): + raise ValueError(f"Coefficients a,...,i must form a valid rotation matrix.") + if not isinstance(self.function, ImplicitFunction): + raise TypeError(f"func expected type is an ImplicitFunction, but a '{type(function)}' type was given.") + + x0 = SurfaceCoefficient('x0') + y0 = SurfaceCoefficient('y0') + z0 = SurfaceCoefficient('z0') + a = SurfaceCoefficient('a') + b = SurfaceCoefficient('b') + c = SurfaceCoefficient('c') + d = SurfaceCoefficient('d') + e = SurfaceCoefficient('e') + f = SurfaceCoefficient('f') + g = SurfaceCoefficient('g') + h = SurfaceCoefficient('h') + i = SurfaceCoefficient('i') + + def __repr__(self): + stringlines = super().__repr__().split('\n') + fline = '\n{0: <20}{1}{2}\n'.format('\tFunction', '=\t', self.function) + isoline = '{0: <20}{1}{2}\n'.format('\tIsovalue', '=\t', self.isovalue) + string = "\n".join(stringlines[:4]) + fline + isoline + "\n".join(stringlines[4:]) + return string + + def _is_valid_rotation(self): + Rmat = self.get_rotation_matrix() + if not np.allclose(Rmat @ Rmat.T, np.identity(3), rtol=0., atol=self._atol): return False + if not np.isclose(np.linalg.det(Rmat), 1.0, rtol=0., atol=self._atol): return False + return True + + def get_rotation_matrix(self): + return np.array([[self.a,self.b,self.c],[self.d,self.e,self.f],[self.g,self.h,self.i]]) + + def bounding_box(self, side): + return BoundingBox.infinite() + + def clone(self, memo=None): + if memo is None: + memo = {} + # If no memoize'd clone exists, instantiate one + if self not in memo: + clone = deepcopy(self) + clone.function = self.function + clone.id = None + # Memoize the clone + memo[self] = clone + return memo[self] + + def normalize(self, coeffs=None): + if coeffs is None: + coeffs = self._get_base_coeffs() + coeffs = np.asarray(coeffs) + return tuple([c for c in coeffs]) + + def is_equal(self, other: ImplicitSurface): + coeffs1 = self._get_base_coeffs() + coeffs2 = other._get_base_coeffs() + if not np.allclose(coeffs1, coeffs2, rtol=0., atol=self._atol): return False + if not np.isclose(self.isovalue, other.isovalue, rtol=0., atol=self._atol): return False + if not self.function is other.function: return False + return True + + def _get_base_coeffs(self): + return self.x0, self.y0, self.z0, self.a, self.b, self.c, self.d, self.e, self.f, self.g, self.h, self.i + + def evaluate(self, point): + Rmat = self.get_rotation_matrix() + newpoint = Rmat @ np.array([point[0] - self.x0, point[1] - self.y0, point[2] - self.z0]) + return self.function.evaluate(newpoint) - self.isovalue + + def translate(self, vector, inplace=False): + if np.allclose(vector, 0., rtol=0., atol=self._atol): + return self if inplace else self.clone() + + x0, y0, z0 = self._get_base_coeffs()[:3] + x0 += vector[0] + y0 += vector[1] + z0 += vector[2] + + surf = self if inplace else self.clone() + + setattr(surf, surf._coeff_keys[0], x0) + setattr(surf, surf._coeff_keys[1], y0) + setattr(surf, surf._coeff_keys[2], z0) + + return surf + + def rotate(self, rotation, pivot=(0., 0., 0.), order='xyz', inplace=False): + pivot = np.asarray(pivot) + rotation = np.asarray(rotation, dtype=float) + + # Allow rotation matrix to be passed in directly, otherwise build it + if rotation.ndim == 2: + check_length('surface rotation', rotation.ravel(), 9) + Rmat = rotation + else: + Rmat = get_rotation_matrix(rotation, order=order) + + # Translate surface to pivot + surf = self.translate(-pivot, inplace=inplace) + x0, y0, z0, a, b, c, d, e, f, g, h, i = surf._get_base_coeffs() + + # Compute new rotated coefficients a, b, c + newR = Rmat @ surf.get_rotation_matrix() + x0, y0, z0 = Rmat @ np.array([x0, y0, z0]) + a, b, c = newR[0,:] + d, e, f = newR[1,:] + g, h, i = newR[2,:] + + kwargs = {'boundary_type': surf.boundary_type, + 'albedo': surf.albedo, + 'name': surf.name} + if inplace: + kwargs['surface_id'] = surf.id + + surf = ImplicitSurface(surf.function, surf.isovalue, x0, y0, z0, a, b, c, d, e, f, g, h, i, **kwargs) + + return surf.translate(pivot, inplace=inplace) + + def to_xml_element(self): + root = super().to_xml_element() + root.set("isovalue", str(self.isovalue)) + fnode = ET.Element("function") + fnode.append(self.function.to_xml_element([])) + root.append(fnode) + return root + + @staticmethod + def from_xml_element(elem): + return super().from_xml_element(elem) + + @staticmethod + def from_hdf5(group): + # TODO: implement when c++ ready + return super().from_hdf5(group) + +class TPMS(ImplicitSurface): + + @classmethod + def from_pitch_isovalue(cls, tpms:str, pitch:float, isovalue:float, **kwargs): + # Shortcuts + X, Y, Z = implicit.X, implicit.Y, implicit.Z + Cos, Sin = implicit.Cos, implicit.Sin + Cached = implicit.Cached + # Get cached or uncached variables, for performance improvement + def _get_xyz(cached=False): + x = 2 * np.pi * X() / pitch + y = 2 * np.pi * Y() / pitch + z = 2 * np.pi * Z() / pitch + if cached: + return Cached(x), Cached(x), Cached(x) + else: + return x, y, z + # Choice of TPMS + if tpms.lower() in ["primitive", "schwarz_p"]: + x, y, z = _get_xyz(False) + func = Cos(x) + Cos(y) + Cos(z) + elif tpms.lower() in ["gyroid", "schoen-g"]: + x, y, z = _get_xyz(True) + func = Sin(x)*Cos(z) + Sin(y)*Cos(x) + Sin(z)*Cos(y) + elif tpms.lower() in ["diamond", "schwarz_d"]: + x, y, z = _get_xyz(True) + func = Sin(x)*Cos(y - z) + Sin(y + z)*Cos(x) + else: + raise NotImplementedError(f"The TPMS named '{tpms.lower()}' is not implemented.") + return cls(func, isovalue, **kwargs) + class Halfspace(Region): """A positive or negative half-space region. @@ -2840,3 +3029,5 @@ def rotate(self, rotation, pivot=(0., 0., 0.), order='xyz', inplace=False, ZCone._virtual_base = Cone Sphere._virtual_base = Sphere Quadric._virtual_base = Quadric +ImplicitSurface._virtual_base = ImplicitSurface +TPMS._virtual_base = TPMS diff --git a/tests/unit_tests/test_implicit.py b/tests/unit_tests/test_implicit.py new file mode 100644 index 00000000000..a668713937f --- /dev/null +++ b/tests/unit_tests/test_implicit.py @@ -0,0 +1,244 @@ +""" +Temporary pytest suite for openmc/implicit_function.py +""" +import pytest +import numpy as np +import lxml.etree as ET + +from openmc.implicit import ( + ImplicitFunction, + X, Y, Z, Constant, + Add, Sub, Mul, Div, Scale, Neg, Pow, + Sin, Cos, Sqrt, Exp, Log, Abs, + Cached, +) + +# Canonical test point used throughout +PT = (1.0, 2.0, 3.0) + + +# ── helpers ──────────────────────────────────────────────────────────────── + +def xml_roundtrip(func: ImplicitFunction, point=PT) -> float: + """Serialise → deserialise → evaluate. Must match the original.""" + elem = func.to_xml_element([]) + restored = ImplicitFunction.from_xml_element(elem) + return restored.evaluate(point) + + +# ── terminals ────────────────────────────────────────────────────────────── + +def test_x(): assert X().evaluate(PT) == 1.0 +def test_y(): assert Y().evaluate(PT) == 2.0 +def test_z(): assert Z().evaluate(PT) == 3.0 +def test_constant(): assert Constant(5.0).evaluate(PT) == 5.0 +def test_constant_int(): assert Constant(3).evaluate(PT) == 3.0 + + +# ── arithmetic ───────────────────────────────────────────────────────────── + +def test_add(): assert Add(X(), Y()).evaluate(PT) == 3.0 +def test_sub(): assert Sub(X(), Y()).evaluate(PT) == -1.0 +def test_mul(): assert Mul(X(), Y()).evaluate(PT) == 2.0 +def test_div(): assert Div(Y(), X()).evaluate(PT) == 2.0 +def test_scale(): assert Scale(X(), 3.0).evaluate(PT) == 3.0 +def test_neg(): assert Neg(X()).evaluate(PT) == -1.0 +def test_pow_int(): assert Pow(Y(), 2).evaluate(PT) == 4.0 +def test_pow_float(): assert Pow(Y(), 0.5).evaluate(PT) == pytest.approx(np.sqrt(2.0)) + + +# ── transcendentals ──────────────────────────────────────────────────────── + +def test_sin(): assert Sin(Constant(np.pi / 2)).evaluate(PT) == pytest.approx(1.0) +def test_cos(): assert Cos(Constant(0.0)).evaluate(PT) == pytest.approx(1.0) +def test_sqrt(): assert Sqrt(Constant(4.0)).evaluate(PT) == pytest.approx(2.0) +def test_exp(): assert Exp(Constant(0.0)).evaluate(PT) == pytest.approx(1.0) +def test_log(): assert Log(Constant(np.e)).evaluate(PT) == pytest.approx(1.0) +def test_abs_pos(): assert Abs(X()).evaluate(PT) == 1.0 +def test_abs_neg(): assert Abs(Neg(X())).evaluate(PT) == 1.0 + + +# ── domain guards ────────────────────────────────────────────────────────── + +def test_div_zero(): + with pytest.raises(ValueError, match="zero"): + Div(X(), Constant(0.0)).evaluate(PT) + +def test_sqrt_negative(): + with pytest.raises(ValueError, match="negative"): + Sqrt(Constant(-1.0)).evaluate(PT) + +def test_log_negative(): + with pytest.raises(ValueError, match="negative"): + Log(Constant(-1.0)).evaluate(PT) + +def test_pow_non_scalar_via_operator(): + with pytest.raises(TypeError): + X() ** Y() + + +# ── operator overloading ─────────────────────────────────────────────────── + +def test_add(): assert (X() + Y()).evaluate(PT) == 3.0 +def test_radd(): assert (1.0 + X()).evaluate(PT) == 2.0 +def test_sub(): assert (X() - Y()).evaluate(PT) == -1.0 +def test_rsub(): assert (3.0 - X()).evaluate(PT) == 2.0 +def test_mul_func(): assert (X() * Y()).evaluate(PT) == 2.0 +def test_mul_scalar(): assert (X() * 3.0).evaluate(PT) == 3.0 +def test_rmul_scalar(): assert (3.0 * X()).evaluate(PT) == 3.0 +def test_div(): assert (Y() / X()).evaluate(PT) == 2.0 +def test_rdiv(): assert (2.0 / Y()).evaluate(PT) == 1.0 +def test_neg(): assert (-X()).evaluate(PT) == -1.0 +def test_pow(): assert (Y() ** 2).evaluate(PT) == 4.0 + +def test_scalar_mul_produces_Scale(): assert isinstance(2 * X(), Scale) +def test_func_mul_produces_Mul(): assert isinstance(X() * Y(), Mul) + +def test_nested(): + f = X()**2 + Y()**2 + Z()**2 + assert f.evaluate(PT) == pytest.approx(14.0) + + +# ── XML tag correctness ──────────────────────────────────────────────────── + +@pytest.mark.parametrize("node,expected_tag", [ + (X(), "x"), + (Y(), "y"), + (Z(), "z"), + (Constant(1.0), "constant"), + (Add(X(), Y()), "add"), + (Sub(X(), Y()), "sub"), + (Mul(X(), Y()), "mul"), + (Div(X(), Y()), "div"), + (Scale(X(), 2.0), "scale"), + (Neg(X()), "neg"), + (Pow(X(), 2), "pow"), + (Sin(X()), "sin"), + (Cos(X()), "cos"), + (Sqrt(X()), "sqrt"), + (Exp(X()), "exp"), + (Log(X()), "log"), + (Abs(X()), "abs"), +]) +def test_tag(node, expected_tag): + assert node.to_xml_element([]).tag == expected_tag + +def test_constant_value_attr(): + assert float(Constant(3.14).to_xml_element([]).get("value")) == pytest.approx(3.14) + +def test_scale_value_attr(): + assert float(Scale(X(), 2.5).to_xml_element([]).get("value")) == pytest.approx(2.5) + +def test_pow_value_attr(): + assert float(Pow(X(), 3.0).to_xml_element([]).get("value")) == pytest.approx(3.0) + +def test_binary_node_has_two_children(): + for binary in [Add, Sub, Mul, Div]: + elem = binary(X(), Y()).to_xml_element([]) + assert len(elem) == 2 + +def test_unary_node_has_one_child(): + for unary in [Neg, Sin, Cos, Abs, Exp, Log, Sqrt]: + elem = unary(X()).to_xml_element([]) + assert len(elem) == 1 + for unary in [Scale, Pow]: + elem = unary(X(), 1.).to_xml_element([]) + assert len(elem) == 1 + + +# ── Cached serialisation ─────────────────────────────────────────────────── + +def test_single_use_emits_to_cache(): + elem = Cached(X()).to_xml_element([]) + assert elem.tag == "to_cache" + assert elem.get("id") == "0" + +def test_to_cache_wraps_child(): + elem = Cached(X()).to_xml_element([]) + assert len(elem) == 1 + assert elem[0].tag == "x" + +def test_shared_node_first_to_cache_then_from_cache(): + node = Cached(X()) + _cached = [] + first = node.to_xml_element(_cached) + second = node.to_xml_element(_cached) + assert first.tag == "to_cache" + assert second.tag == "from_cache" + assert first.get("id") == second.get("id") == "0" + +def test_distinct_cached_nodes_get_distinct_ids(): + node1 = Cached(X()) + node2 = Cached(Y()) + _cached = [] + ex, ey = Add(node1, node2).to_xml_element(_cached) + assert ex.get("id") == "0" + assert ey.get("id") == "1" + +def test_cached_in_expression(): + cx = Cached(X()) + elem = (cx + cx).to_xml_element([]) + assert elem.tag == "add" + assert elem[0].tag == "to_cache" + assert elem[1].tag == "from_cache" + assert elem[0].get("id") == elem[1].get("id") + + +# ── XML round-trip ───────────────────────────────────────────────────────── + +@pytest.mark.parametrize("func,expected", [ + (X(), 1.0), + (Y(), 2.0), + (Z(), 3.0), + (Constant(7.0), 7.0), + (Add(X(), Y()), 3.0), + (Sub(Z(), X()), 2.0), + (Mul(X(), Y()), 2.0), + (Div(Y(), X()), 2.0), + (Scale(Z(), 2.0), 6.0), + (Neg(X()), -1.0), + (Pow(Y(), 2), 4.0), + (Sin(Constant(0.0)), 0.0), + (Cos(Constant(0.0)), 1.0), + (Sqrt(Constant(9.0)), 3.0), + (Exp(Constant(0.0)), 1.0), + (Log(Constant(1.0)), 0.0), + (Abs(Neg(Y())), 2.0), + (X()**2 + Y()**2 + Z()**2, 14.0), +]) +def test_roundtrip(func, expected): + assert xml_roundtrip(func) == pytest.approx(expected) + +def test_roundtrip_cached_single(): + cx = Cached(X()) + f = Sin(cx) + Cos(cx) + assert xml_roundtrip(f) == pytest.approx(np.sin(1.0) + np.cos(1.0)) + +def test_roundtrip_cached_shared(): + cx = Cached(2.0 * X()) + f = cx * cx # (2*1)^2 = 4 + assert xml_roundtrip(f) == pytest.approx(4.0) + +def test_roundtrip_deeply_nested(): + f = Sin(Cos(X() + Constant(np.pi))) + assert xml_roundtrip(f) == pytest.approx(f.evaluate(PT)) + + +# ── TPMS-like integration smoke test ────────────────────────────────────── + +@pytest.fixture +def gyroid(): + L = 1.0 + k = 2 * np.pi / L + cx = Cached(k * X()) + cy = Cached(k * Y()) + cz = Cached(k * Z()) + return Sin(cx)*Cos(cz) + Sin(cy)*Cos(cx) + Sin(cz)*Cos(cy) + +def test_gyroid_evaluates(gyroid): + # value at origin should be 0 for standard gyroid + assert gyroid.evaluate((0.0, 0.0, 0.0)) == pytest.approx(0.0) + +def test_gyroid_roundtrip(gyroid): + pt = (0.1, 0.2, 0.3) + assert xml_roundtrip(gyroid, pt) == pytest.approx(gyroid.evaluate(pt)) \ No newline at end of file From 9b1fdba80ef6fee3b47aa3a622fd5cb358ce0a7c Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Tue, 26 May 2026 16:21:28 -0600 Subject: [PATCH 03/22] Removed dead code in rmul.\nAdd docstring to Cached to document correct use.\nAdd a cache checker to warn for unused cached functions.\nAdd a test for wrong xml tag import. --- openmc/implicit.py | 16 +++++++++++++++- openmc/surface.py | 8 +++++++- tests/unit_tests/test_implicit.py | 17 +++++++++++------ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/openmc/implicit.py b/openmc/implicit.py index 9e76f28d294..b22a1dc1d23 100644 --- a/openmc/implicit.py +++ b/openmc/implicit.py @@ -109,7 +109,6 @@ def __mul__(self, other: float | ImplicitFunction) -> ImplicitFunction: def __rmul__(self, other: float | ImplicitFunction) -> ImplicitFunction: if isinstance(other, (int, float)): return Scale(self, float(other)) - return Mul(self, other) # --------------------------------------------------------------------------- # Terminal nodes @@ -319,6 +318,21 @@ def to_xml_element(self, _cached=None): # --------------------------------------------------------------------------- class Cached(ImplicitFunction): + """ + Marks a subexpression for memoisation in C++. + + The Python object identity (id()) is used to detect shared nodes during + XML serialisation. This means the SAME Python object must be reused + wherever you want sharing to occur — do NOT construct a new Cached() + at each use site. + + Correct — one object, two references: + cx = Cached(2 * np.pi * X()) + f = Sin(cx) * Cos(cx) # cx serialised once as + + Wrong — two objects, same expression: + f = Sin(Cached(2 * np.pi * X())) * Cos(Cached(2 * np.pi * X())) + """ def __repr__(self): return f"@[{self.f}]" def __init__(self, f:ImplicitFunction): self.f = f diff --git a/openmc/surface.py b/openmc/surface.py index b74958f1476..ffb21d93a0d 100644 --- a/openmc/surface.py +++ b/openmc/surface.py @@ -2727,7 +2727,13 @@ def to_xml_element(self): root = super().to_xml_element() root.set("isovalue", str(self.isovalue)) fnode = ET.Element("function") - fnode.append(self.function.to_xml_element([])) + cached_list = [] + fnode.append(self.function.to_xml_element(cached_list)) + # Check cached nodes + from_cache_ids = {int(e.get("id")) for e in fnode.iter("from_cache")} + for i in range(len(cached_list)): + if i not in from_cache_ids: + warn(f"Cached node id={i} has no reference and is only used once. Did you forget to reuse it?", UserWarning) root.append(fnode) return root diff --git a/tests/unit_tests/test_implicit.py b/tests/unit_tests/test_implicit.py index a668713937f..835d5411955 100644 --- a/tests/unit_tests/test_implicit.py +++ b/tests/unit_tests/test_implicit.py @@ -145,6 +145,10 @@ def test_unary_node_has_one_child(): elem = unary(X(), 1.).to_xml_element([]) assert len(elem) == 1 +def test_wrong_tag(): + bad_elem = ET.Element("tanh") # not in the dispatch table + with pytest.raises(ValueError, match="Unknown tag 'tanh'"): + ImplicitFunction.from_xml_element(bad_elem) # ── Cached serialisation ─────────────────────────────────────────────────── @@ -168,12 +172,13 @@ def test_shared_node_first_to_cache_then_from_cache(): assert first.get("id") == second.get("id") == "0" def test_distinct_cached_nodes_get_distinct_ids(): - node1 = Cached(X()) - node2 = Cached(Y()) - _cached = [] - ex, ey = Add(node1, node2).to_xml_element(_cached) - assert ex.get("id") == "0" - assert ey.get("id") == "1" + node1 = Cached(X()) + node2 = Cached(Y()) + _cached = [] + ex = node1.to_xml_element(_cached) + ey = node2.to_xml_element(_cached) + assert ex.get("id") == "0" + assert ey.get("id") == "1" def test_cached_in_expression(): cx = Cached(X()) From 74e528150dbe7281308a906be545f3c10d7ae9ff Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Mon, 1 Jun 2026 12:26:11 -0600 Subject: [PATCH 04/22] *** Major commit Implicit Functions --- CMakeLists.txt | 2 + README.md | 2 + include/openmc/constants.h | 2 +- include/openmc/implicit.h | 483 +++++++++ include/openmc/implicit_solvers.h | 73 ++ include/openmc/surface.h | 29 + openmc/implicit.py | 20 +- openmc/surface.py | 21 +- src/cell.cpp | 38 +- src/geometry.cpp | 2 + src/implicit.cpp | 996 ++++++++++++++++++ src/implicit_solvers.cpp | 80 ++ src/surface.cpp | 78 +- tests/regression_tests/implicit/__init__.py | 0 .../implicit/gyroid/__init__.py | 0 .../implicit/gyroid/inputs_true.dat | 78 ++ .../implicit/gyroid/results_true.dat | 2 + .../regression_tests/implicit/gyroid/test.py | 42 + .../implicit/sphere/__init__.py | 0 .../implicit/sphere/inputs_true.dat | 45 + .../implicit/sphere/results_true.dat | 2 + .../regression_tests/implicit/sphere/test.py | 43 + tests/unit_tests/test_implicit.py | 9 +- tests/unit_tests/test_implicit_surface.py | 523 +++++++++ 24 files changed, 2549 insertions(+), 21 deletions(-) create mode 100644 include/openmc/implicit.h create mode 100644 include/openmc/implicit_solvers.h create mode 100644 src/implicit.cpp create mode 100644 src/implicit_solvers.cpp create mode 100644 tests/regression_tests/implicit/__init__.py create mode 100644 tests/regression_tests/implicit/gyroid/__init__.py create mode 100644 tests/regression_tests/implicit/gyroid/inputs_true.dat create mode 100644 tests/regression_tests/implicit/gyroid/results_true.dat create mode 100644 tests/regression_tests/implicit/gyroid/test.py create mode 100644 tests/regression_tests/implicit/sphere/__init__.py create mode 100644 tests/regression_tests/implicit/sphere/inputs_true.dat create mode 100644 tests/regression_tests/implicit/sphere/results_true.dat create mode 100644 tests/regression_tests/implicit/sphere/test.py create mode 100644 tests/unit_tests/test_implicit_surface.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 9fe133a22e3..09d9df3fa7e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -380,6 +380,8 @@ list(APPEND libopenmc_SOURCES src/geometry_aux.cpp src/hdf5_interface.cpp src/ifp.cpp + src/implicit.cpp + src/implicit_solvers.cpp src/initialize.cpp src/lattice.cpp src/material.cpp diff --git a/README.md b/README.md index 6750c39779c..866f24b5ae9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # For Implicit Function development This branch aims to develop a framework to create any surface defined by an implicit function. A dedicated solver and architecture is proposed. +Developer cross sections : https://anl.box.com/shared/static/teaup95cqv8s9nn56hfn7ku8mmelr95p.xz + # OpenMC Monte Carlo Particle Transport Code [![License](https://img.shields.io/badge/license-MIT-green)](https://docs.openmc.org/en/latest/license.html) diff --git a/include/openmc/constants.h b/include/openmc/constants.h index 0b425a673dc..e1987eaf03d 100644 --- a/include/openmc/constants.h +++ b/include/openmc/constants.h @@ -372,7 +372,7 @@ enum class RandomRaySampleMethod { PRNG, HALTON, S2 }; //============================================================================== // Geometry Constants -enum class GeometryType { CSG, DAG }; +enum class GeometryType { CSG, DAG, IMP }; // a surface token cannot be zero due to the unsigned nature of zero for integer // representations. This value represents no surface. diff --git a/include/openmc/implicit.h b/include/openmc/implicit.h new file mode 100644 index 00000000000..5e1ddfd117f --- /dev/null +++ b/include/openmc/implicit.h @@ -0,0 +1,483 @@ +#ifndef OPENMC_IMPLICIT_H +#define OPENMC_IMPLICIT_H + +#include +#include +#include // For numeric_limits +#include +#include +#include +#include + +#include "pugixml.hpp" + +#include "openmc/position.h" + +namespace openmc { + +//============================================================================== +// Type aliases +//============================================================================== + +// Gradient shares the memory layout of Position (x, y, z doubles). +// Using a named alias makes intent explicit at every call site. +using Gradient = Position; + +//============================================================================== +// StepCache +// +// Thread-local epoch counter. Increment step_cache.epoch once at the +// start of each geometry step. All Cached nodes across the DAG are +// lazily invalidated at zero cost — no traversal needed. +//============================================================================== + +struct CacheEntry { + uint64_t epoch {std::numeric_limits::max()}; + Position pos {}; + double val {0.0}; + Gradient grad {0.0, 0.0, 0.0}; +}; + +struct StepCache { + uint64_t epoch {0}; + // Per-thread cache keyed by node address. + // Grows to at most one entry per Cached node in the model — bounded and tiny. + std::unordered_map node_cache; +}; + +extern thread_local StepCache step_cache; + +//============================================================================== +// Implicit +// +// Abstract base class for expression DAG nodes representing a smooth +// 3D function f(x, y, z). Concrete subclasses are defined in the +// openmc::implicit namespace below. +// +// The DAG is constructed in Python (implicit_function.py) and serialised +// to XML. On the C++ side it is reconstructed via from_xml_element() and +// evaluated during implicit surface geometry queries. +//============================================================================== + +class Implicit { +public: + virtual ~Implicit() = default; + + virtual std::string expression() const = 0; + //! Evaluate f(r). + virtual double evaluate(Position r) const = 0; + + //! Gradient df/dx, df/dy, df/dz. + //! Computed analytically — no finite differences. + virtual Gradient gradient(Position r) const = 0; + + //! Evaluate f along the ray: f(r + t*u). + double along_ray(double t, Position r, Direction u) const; + + //! Lipschitz constant of f on the ray segment [t0, t1]: + //! |f(r + a*u) - f(r + b*u)| <= L * |a - b| for all a,b in [t0,t1]. + //! Computed analytically via interval arithmetic propagation. + virtual double compute_lipschitz( + Position r, Direction u, double t0, double t1) const = 0; + + //! Tight lower and upper bounds of f on the ray segment [t0, t1]. + //! Returned as {f_min, f_max}. + //! Computed jointly because many nodes (Mul, Div, Pow) need both bounds + //! together to propagate the interval correctly. + virtual std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const = 0; + + // create a xml element out of the node + virtual pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const = 0; + + // Convenience wrapper — serialises the whole DAG to an XML string + std::string to_xml_string() const; + + //! Reconstruct an Implicit DAG from an XML element produced by + //! Python's to_xml_element(). + //! + //! \param node The XML element to parse. + //! \param cache_map Maps numeric ids to already-constructed shared nodes. + //! Populated when a element is encountered; + //! looked up when a element is encountered. + //! Passed by reference through the recursion. + static std::shared_ptr from_xml_element(pugi::xml_node node, + std::unordered_map>& cache_map); + + //! Convenience overload for the root call — creates an empty cache_map. + static std::shared_ptr from_xml_element(pugi::xml_node node); +}; + +//============================================================================== +// Concrete node types (openmc::implicit namespace) +// +// All implementations are in implicit.cpp. +// Nodes follow the expression tree structure of the Python counterparts in +// implicit_function.py. The openmc::implicit sub-namespace avoids name +// collisions with common identifiers (X, Y, Z, Log, Exp, etc.). +//============================================================================== + +namespace implicit { + +// ============================================================================ +// Terminal nodes +// ============================================================================ + +class X final : public Implicit { +public: + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; +}; + +class Y final : public Implicit { +public: + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; +}; + +class Z final : public Implicit { +public: + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; +}; + +class Constant final : public Implicit { +public: + explicit Constant(double value) : value_(value) {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + double value_; +}; + +// ============================================================================ +// Arithmetic nodes +// ============================================================================ + +class Add final : public Implicit { +public: + explicit Add(std::shared_ptr f, std::shared_ptr g) + : f_(std::move(f)), g_(std::move(g)) + {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr f_, g_; +}; + +class Sub final : public Implicit { +public: + explicit Sub(std::shared_ptr f, std::shared_ptr g) + : f_(std::move(f)), g_(std::move(g)) + {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr f_, g_; +}; + +class Mul final : public Implicit { +public: + explicit Mul(std::shared_ptr f, std::shared_ptr g) + : f_(std::move(f)), g_(std::move(g)) + {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr f_, g_; +}; + +class Div final : public Implicit { +public: + explicit Div(std::shared_ptr f, std::shared_ptr g) + : f_(std::move(f)), g_(std::move(g)) + {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr f_, g_; +}; + +class Scale final : public Implicit { +public: + explicit Scale(std::shared_ptr f, double k) + : f_(std::move(f)), k_(k) + {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr f_; + double k_; +}; + +class Neg final : public Implicit { +public: + explicit Neg(std::shared_ptr f) : f_(std::move(f)) {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr f_; +}; + +class Pow final : public Implicit { +public: + explicit Pow(std::shared_ptr f, int exp) + : f_(std::move(f)), exp_(exp) + { + if (exp_ <= 0) + throw std::domain_error( + "Pow: exponent must be a strictly positive integer, got " + + std::to_string(exp_)); + } + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr f_; + int exp_; +}; + +// ============================================================================ +// Transcendental nodes +// ============================================================================ + +class Sin final : public Implicit { +public: + explicit Sin(std::shared_ptr arg) : arg_(std::move(arg)) {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr arg_; +}; + +class Cos final : public Implicit { +public: + explicit Cos(std::shared_ptr arg) : arg_(std::move(arg)) {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr arg_; +}; + +class Sqrt final : public Implicit { +public: + explicit Sqrt(std::shared_ptr arg) : arg_(std::move(arg)) {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr arg_; +}; + +class Exp final : public Implicit { +public: + explicit Exp(std::shared_ptr arg) : arg_(std::move(arg)) {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr arg_; +}; + +class Log final : public Implicit { +public: + explicit Log(std::shared_ptr arg) : arg_(std::move(arg)) {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr arg_; +}; + +class Abs final : public Implicit { +public: + explicit Abs(std::shared_ptr arg) : arg_(std::move(arg)) {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr arg_; +}; + +// ============================================================================ +// Cached node +// +// Memoises evaluate() and gradient() for the duration of one geometry step +// AND one position. A cache hit requires BOTH a matching epoch AND a +// matching position. +// +// Epoch-only invalidation is INCORRECT: the ray-tracing solver evaluates f +// at many different positions within a single step, so the position check +// is essential for correctness. +// +// compute_lipschitz and compute_f_min_max are NOT cached — they operate on +// intervals, not single points, so the per-point cache does not apply. +// Both methods delegate directly to the child. +// +// Thread safety: Cached has no mutable members. All cache data lives in +// thread_local step_cache.node_cache, keyed by node address. Each thread +// maintains a fully independent cache — no shared mutable state, no data race. +// +// The map grows to at most one entry per Cached node in the model and is +// never cleared — entries are simply overwritten when the epoch or position +// changes. +// +// WARNING: the SAME shared_ptr instance must be used wherever this +// subexpression appears in the DAG. Constructing two separate Cached nodes +// wrapping the same expression gives two independent caches and no XML +// sharing. See the Python-side docstring in implicit_function.py for the +// full pitfall and correct usage pattern. +// ============================================================================ + +class Cached final : public Implicit { +public: + explicit Cached(std::shared_ptr child) : child_(std::move(child)) {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + //! Recompute value and gradient if epoch or position changed. + void refresh(Position r) const; + + std::shared_ptr child_; +}; + +} // namespace implicit +} // namespace openmc +#endif // OPENMC_IMPLICIT_H diff --git a/include/openmc/implicit_solvers.h b/include/openmc/implicit_solvers.h new file mode 100644 index 00000000000..72ca608b6b4 --- /dev/null +++ b/include/openmc/implicit_solvers.h @@ -0,0 +1,73 @@ +#ifndef OPENMC_IMPLICIT_SOLVER_H +#define OPENMC_IMPLICIT_SOLVER_H + +#include "openmc/implicit.h" +#include "openmc/position.h" + +namespace openmc { + +//============================================================================== +// ImplicitSolver +// +// Abstract base class for ray-implicit surface intersection solvers. +// +// Contract: +// - func has already been shifted by isovalue (SurfaceImplicit::evaluate +// returns f(r) - c, so the solver always looks for f = 0). +// - [t0, t1] is already clipped to the bounding box by the caller. +// - Returns the smallest positive root in [t0, t1], or INFTY if none found. +//============================================================================== + +class ImplicitSolver { +public: + ImplicitSolver(double atol = 1e-8, int max_iter = 1000000) + : atol_(atol), max_iter_(max_iter) + {} + virtual ~ImplicitSolver() = default; + + //! Find the smallest root of func(r + t*u) = 0 in [t0, t1]. + virtual double solve(const Implicit& func, Position r, Direction u, double t0, + double t1, double isovalue = 0.0) const = 0; + + // access atol outside the solver + double get_atol() const { return atol_; } + +protected: + double atol_; + int max_iter_; +}; + +//============================================================================== +// NaiveLipschitz +// +// Lipschitz-marching solver. At each step, the Lipschitz constant L gives a +// guaranteed safe skip distance: if |f(t)| / L > remaining interval, no root +// is possible and we advance. When a sign change is detected, bisection +// finds the root to within atol. +// +// The Lipschitz constant is computed once over [t0, t1] at the start and +// reused throughout the march. This is the "naive" part — a more +// sophisticated solver would recompute L on each sub-interval. +// +// Parameters: +// atol Geometric tolerance for root detection (metres). A value of +// 1e-8 (roughly one atomic radius) is a sensible default. +// max_iter Safety cap on the number of marching steps. If reached, the +// solver returns INFTY and the surface is treated as not hit. +//============================================================================== + +class NaiveLipschitz : public ImplicitSolver { +public: + using ImplicitSolver::ImplicitSolver; + double solve(const Implicit& function, Position r, Direction u, double t0, + double t1, double isovalue) const override; + +private: + //! Bisect on [ta, tb] given that f(ta) and f(tb) have opposite signs. + //! Returns the root to within atol_. + double bisect(const Implicit& func, Position r, Direction u, double ta, + double tb, double fa, double fb, double isovalue) const; +}; + +} // namespace openmc +#endif // OPENMC_IMPLICIT_SOLVER_H \ No newline at end of file diff --git a/include/openmc/surface.h b/include/openmc/surface.h index 2d8580345a4..5e5ea1d2211 100644 --- a/include/openmc/surface.h +++ b/include/openmc/surface.h @@ -12,6 +12,8 @@ #include "openmc/boundary_condition.h" #include "openmc/bounding_box.h" #include "openmc/constants.h" +#include "openmc/implicit.h" +#include "openmc/implicit_solvers.h" #include "openmc/memory.h" // for unique_ptr #include "openmc/particle.h" #include "openmc/position.h" @@ -375,6 +377,33 @@ class SurfaceZTorus : public Surface { double x0_, y0_, z0_, A_, B_, C_; }; +//============================================================================== +//! An implicit surface described by a user specified function +//============================================================================== + +class SurfaceImplicit : public Surface { +public: + explicit SurfaceImplicit(pugi::xml_node surf_node); + double evaluate(Position r) const override; + double distance_finite( + Position r, Direction u, bool coincident, double distance_max) const; + double distance(Position r, Direction u, bool coincident) const override; + Direction normal(Position r) const override; + void to_hdf5_inner(hid_t group_id) const override; + GeometryType geom_type() const override { return GeometryType::IMP; } + +private: + //! Apply the surface transform: r_local = R * (r - r0) + Position transform(Position r) const; + //! Apply rotation only (no translation) for directions: u_local = R * u + Direction transform_dir(Direction u) const; + + double x0_, y0_, z0_, A_, B_, C_, D_, E_, F_, G_, H_, I_; + double isovalue_; + std::shared_ptr function_; + std::unique_ptr solver_; +}; + //============================================================================== // Non-member functions //============================================================================== diff --git a/openmc/implicit.py b/openmc/implicit.py index b22a1dc1d23..ada53c9da6a 100644 --- a/openmc/implicit.py +++ b/openmc/implicit.py @@ -56,7 +56,7 @@ def from_xml_element(cls, element: ET.Element, _cached: dict | None = None) -> I "scale": lambda: Scale(children[0], float(attrib["value"])), "mul": lambda: Mul(*children), "div": lambda: Div(*children), - "pow": lambda: Pow(children[0], float(attrib["value"])), + "pow": lambda: Pow(children[0], int(attrib["value"])), "sin": lambda: Sin(*children), "cos": lambda: Cos(*children), "sqrt": lambda: Sqrt(*children), @@ -96,9 +96,11 @@ def __truediv__(self, other: float | ImplicitFunction) -> ImplicitFunction: def __rtruediv__(self, other: float | ImplicitFunction) -> ImplicitFunction: return Div(_to_function(other), _to_function(self)) - def __pow__(self, exp: int | float) -> ImplicitFunction: - if not isinstance(exp, (int, float)): - raise TypeError(f"Pow exponent must be a scalar, got {type(exp)}") + def __pow__(self, exp: int) -> ImplicitFunction: + if not isinstance(exp, (int)): + raise TypeError(f"Pow exponent must be an int, got {type(exp)}") + if exp <= 0.: + raise TypeError(f"Pow exponent must be strictly positive, got {exp}") return Pow(_to_function(self), exp) def __mul__(self, other: float | ImplicitFunction) -> ImplicitFunction: @@ -226,9 +228,13 @@ def to_xml_element(self, _cached=None): class Pow(ImplicitFunction): def __repr__(self): return f"{self.f} ** {self.exp}" - def __init__(self, f:ImplicitFunction, exp: int | float): - self.f = f - self.exp = float(exp) + def __init__(self, f:ImplicitFunction, exp: int): + if not isinstance(exp, int) or exp <= 0: + raise TypeError( + f"Pow exponent must be a strictly positive integer, got {exp!r}. " + f"For exp=-1 use Div, for exp=0.5 use Sqrt.") + self.f = f + self.exp = exp def evaluate(self, point): return self.f.evaluate(point) ** self.exp def to_xml_element(self, _cached=None): if _cached is None: _cached = [] diff --git a/openmc/surface.py b/openmc/surface.py index ffb21d93a0d..63ddce7d30b 100644 --- a/openmc/surface.py +++ b/openmc/surface.py @@ -473,7 +473,7 @@ def from_xml_element(elem): kwargs.update(dict(zip(cls._coeff_keys, coeffs))) if surf_type == "implicit": - kwargs['func'] = ImplicitFunction.from_xml_element(elem.get("function")) + kwargs['function'] = ImplicitFunction.from_xml_element(elem.find("function")[0]) kwargs['isovalue'] = float(elem.get("isovalue")) return cls(**kwargs) @@ -514,6 +514,16 @@ def from_hdf5(group): surf_type = group['type'][()].decode() cls = _SURFACE_CLASSES[surf_type] + if surf_type == 'implicit': + xml_str = group['function_xml'][()].decode() + func_elem = ET.fromstring(xml_str) + func = ImplicitFunction.from_xml_element(func_elem[0]) + isovalue = float(group['isovalue'][()]) + kwargs.update(dict(zip(cls._coeff_keys, coeffs))) + kwargs['function'] = func + kwargs['isovalue'] = isovalue + return cls(**kwargs) + return cls(*coeffs, **kwargs) @@ -2707,7 +2717,7 @@ def rotate(self, rotation, pivot=(0., 0., 0.), order='xyz', inplace=False): x0, y0, z0, a, b, c, d, e, f, g, h, i = surf._get_base_coeffs() # Compute new rotated coefficients a, b, c - newR = Rmat @ surf.get_rotation_matrix() + newR = surf.get_rotation_matrix() @ Rmat.T x0, y0, z0 = Rmat @ np.array([x0, y0, z0]) a, b, c = newR[0,:] d, e, f = newR[1,:] @@ -2739,12 +2749,11 @@ def to_xml_element(self): @staticmethod def from_xml_element(elem): - return super().from_xml_element(elem) + return Surface.from_xml_element(elem) @staticmethod def from_hdf5(group): - # TODO: implement when c++ ready - return super().from_hdf5(group) + return Surface.from_hdf5(group) class TPMS(ImplicitSurface): @@ -2760,7 +2769,7 @@ def _get_xyz(cached=False): y = 2 * np.pi * Y() / pitch z = 2 * np.pi * Z() / pitch if cached: - return Cached(x), Cached(x), Cached(x) + return Cached(x), Cached(y), Cached(z) else: return x, y, z # Choice of TPMS diff --git a/src/cell.cpp b/src/cell.cpp index 06bc29a6629..b12615423d1 100644 --- a/src/cell.cpp +++ b/src/cell.cpp @@ -22,6 +22,7 @@ #include "openmc/material.h" #include "openmc/nuclide.h" #include "openmc/settings.h" +#include "openmc/surface.h" #include "openmc/xml_interface.h" namespace openmc { @@ -949,6 +950,9 @@ std::pair Region::distance( double min_dist {INFTY}; int32_t i_surf {std::numeric_limits::max()}; + // Implicit surfaces are deferred until we know min_dist from the + // analytical surfaces, which bounds the solver interval. + std::vector implicit_tokens; for (int32_t token : expression_) { // Ignore this token if it corresponds to an operator rather than a region. if (token >= OP_UNION) @@ -956,8 +960,16 @@ std::pair Region::distance( // Calculate the distance to this surface. // Note the off-by-one indexing + Surface* surf = model::surfaces[std::abs(token) - 1].get(); + + // Defer implicit surfaces to pass 2. + if (surf->geom_type() == GeometryType::IMP) { + implicit_tokens.push_back(token); + continue; + } + bool coincident {std::abs(token) == std::abs(on_surface)}; - double d {model::surfaces[abs(token) - 1]->distance(r, u, coincident)}; + double d {surf->distance(r, u, coincident)}; // Check if this distance is the new minimum. if (d < min_dist) { @@ -968,6 +980,30 @@ std::pair Region::distance( } } + // Finite region check + if (!implicit_tokens.empty() && min_dist == INFTY) { + fatal_error( + "An implicit surface belongs to a region with no finite analytical " + "boundary. Implicit surfaces must be enclosed in a finite region " + "defined by standard surfaces (planes, spheres, cylinders, etc.)."); + } + + // Implicit surfaces treatment + for (int32_t token : implicit_tokens) { + auto* surf = + static_cast(model::surfaces[std::abs(token) - 1].get()); + + bool coincident {std::abs(token) == std::abs(on_surface)}; + double d {surf->distance_finite(r, u, coincident, min_dist)}; + + if (d < min_dist) { + if (min_dist - d >= FP_PRECISION * min_dist) { + min_dist = d; + i_surf = -token; + } + } + } + return {min_dist, i_surf}; } diff --git a/src/geometry.cpp b/src/geometry.cpp index ddb61385f18..fe80e945cf3 100644 --- a/src/geometry.cpp +++ b/src/geometry.cpp @@ -365,6 +365,8 @@ BoundaryInfo distance_to_boundary(GeometryState& p) double d_surf = INFINITY; int32_t level_surf_cross; array level_lat_trans {}; + ++step_cache.epoch; // New geometry step: invalidate implicit surface + // subexpression cache. // Loop over each coordinate level. for (int i = 0; i < p.n_coord(); i++) { diff --git a/src/implicit.cpp b/src/implicit.cpp new file mode 100644 index 00000000000..7261335ccaf --- /dev/null +++ b/src/implicit.cpp @@ -0,0 +1,996 @@ +#include "openmc/implicit.h" + +#include // std::min, std::max +#include +#include +#include +#include +#include +#include + +namespace openmc { + +//============================================================================== +// Cache +//============================================================================== + +thread_local StepCache step_cache; + +//============================================================================== +// Implicit +//============================================================================== + +double Implicit::along_ray(double t, Position r, Direction u) const +{ + return evaluate({r.x + t * u.x, r.y + t * u.y, r.z + t * u.z}); +} + +std::shared_ptr Implicit::from_xml_element(pugi::xml_node node, + std::unordered_map>& cache_map) +{ + std::string tag = node.name(); + + // from_cache: look up a previously registered shared node. + // Handled before child parsing - there are no children to recurse into. + if (tag == "from_cache") { + int id = node.attribute("id").as_int(); + auto it = cache_map.find(id); + if (it == cache_map.end()) + throw std::runtime_error( + "Implicit::from_xml_element: from_cache id=" + std::to_string(id) + + " has no matching to_cache."); + return it->second; + } + + // Recursively parse all element children. + std::vector> children; + for (auto child : node.children()) { + if (child.type() == pugi::node_element) + children.push_back(from_xml_element(child, cache_map)); + } + + // to_cache: register the single child node by id, return a Cached wrapper. + if (tag == "to_cache") { + int id = node.attribute("id").as_int(); + auto cached = std::make_shared(children[0]); + cache_map[id] = cached; + return cached; + } + + // Dispatch table - mirrors Python's from_xml_element. + if (tag == "x") + return std::make_shared(); + if (tag == "y") + return std::make_shared(); + if (tag == "z") + return std::make_shared(); + if (tag == "constant") { + double v = node.attribute("value").as_double(); + return std::make_shared(v); + } + if (tag == "add") + return std::make_shared(children[0], children[1]); + if (tag == "sub") + return std::make_shared(children[0], children[1]); + if (tag == "mul") + return std::make_shared(children[0], children[1]); + if (tag == "div") + return std::make_shared(children[0], children[1]); + if (tag == "scale") { + double k = node.attribute("value").as_double(); + return std::make_shared(children[0], k); + } + if (tag == "neg") + return std::make_shared(children[0]); + if (tag == "pow") { + int exp = node.attribute("value").as_int(); + return std::make_shared(children[0], exp); + } + if (tag == "sin") + return std::make_shared(children[0]); + if (tag == "cos") + return std::make_shared(children[0]); + if (tag == "sqrt") + return std::make_shared(children[0]); + if (tag == "exp") + return std::make_shared(children[0]); + if (tag == "log") + return std::make_shared(children[0]); + if (tag == "abs") + return std::make_shared(children[0]); + + throw std::runtime_error( + "Implicit::from_xml_element: unknown tag '" + tag + "'."); +} + +std::shared_ptr Implicit::from_xml_element(pugi::xml_node node) +{ + std::unordered_map> cache_map; + return from_xml_element(node, cache_map); +} + +std::string Implicit::to_xml_string() const +{ + pugi::xml_document doc; + auto func_node = doc.append_child("function"); + std::unordered_map cache_map; + to_xml_element(func_node, cache_map); + std::ostringstream oss; + doc.save(oss); + return oss.str(); +} + +//============================================================================== +// Concrete node types (openmc::implicit namespace) +//============================================================================== + +namespace implicit { + +//============================================================================== +// Helper functions for the product of two intervals [a,b] * [c,d] +//============================================================================== + +static std::pair interval_mul( + double a, double b, double c, double d) +{ + double p1 = a * c, p2 = a * d, p3 = b * c, p4 = b * d; + return {std::min({p1, p2, p3, p4}), std::max({p1, p2, p3, p4})}; +} + +//============================================================================== +// X +//============================================================================== + +std::string X::expression() const +{ + return "X"; +} +double X::evaluate(Position r) const +{ + return r.x; +} +Gradient X::gradient(Position r) const +{ + return {1.0, 0.0, 0.0}; +} +double X::compute_lipschitz(Position r, Direction u, double t0, double t1) const +{ + return std::abs(u.x); +} +std::pair X::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + double f0 = r.x + t0 * u.x; + double f1 = r.x + t1 * u.x; + return {std::min(f0, f1), std::max(f0, f1)}; +} +pugi::xml_node X::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("x"); + return node; +} + +//============================================================================== +// Y +//============================================================================== + +std::string Y::expression() const +{ + return "Y"; +} +double Y::evaluate(Position r) const +{ + return r.y; +} +Gradient Y::gradient(Position r) const +{ + return {0.0, 1.0, 0.0}; +} +double Y::compute_lipschitz(Position r, Direction u, double t0, double t1) const +{ + return std::abs(u.y); +} +std::pair Y::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + double f0 = r.y + t0 * u.y; + double f1 = r.y + t1 * u.y; + return {std::min(f0, f1), std::max(f0, f1)}; +} +pugi::xml_node Y::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("y"); + return node; +} + +//============================================================================== +// Z +//============================================================================== + +std::string Z::expression() const +{ + return "Z"; +} +double Z::evaluate(Position r) const +{ + return r.z; +} +Gradient Z::gradient(Position r) const +{ + return {0.0, 0.0, 1.0}; +} +double Z::compute_lipschitz(Position r, Direction u, double t0, double t1) const +{ + return std::abs(u.z); +} +std::pair Z::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + double f0 = r.z + t0 * u.z; + double f1 = r.z + t1 * u.z; + return {std::min(f0, f1), std::max(f0, f1)}; +} +pugi::xml_node Z::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("z"); + return node; +} + +//============================================================================== +// Constant +//============================================================================== + +std::string Constant::expression() const +{ + return std::to_string(value_); +} +double Constant::evaluate(Position r) const +{ + return value_; +} +Gradient Constant::gradient(Position r) const +{ + return {0.0, 0.0, 0.0}; +} +double Constant::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + return 0.; +} +std::pair Constant::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + return {value_, value_}; +} +pugi::xml_node Constant::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("constant"); + node.append_attribute("value") = value_; + return node; +} + +//============================================================================== +// Add +//============================================================================== + +std::string Add::expression() const +{ + return f_->expression() + " + " + g_->expression(); +} +double Add::evaluate(Position r) const +{ + return f_->evaluate(r) + g_->evaluate(r); +} +Gradient Add::gradient(Position r) const +{ + return f_->gradient(r) + g_->gradient(r); +} +double Add::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + return f_->compute_lipschitz(r, u, t0, t1) + + g_->compute_lipschitz(r, u, t0, t1); +} +std::pair Add::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [f_min, f_max] = f_->compute_f_min_max(r, u, t0, t1); + auto [g_min, g_max] = g_->compute_f_min_max(r, u, t0, t1); + return {f_min + g_min, f_max + g_max}; +} +pugi::xml_node Add::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("add"); + f_->to_xml_element(node, cache_map); + g_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Sub +//============================================================================== + +std::string Sub::expression() const +{ + return f_->expression() + " - " + g_->expression(); +} +double Sub::evaluate(Position r) const +{ + return f_->evaluate(r) - g_->evaluate(r); +} +Gradient Sub::gradient(Position r) const +{ + return f_->gradient(r) - g_->gradient(r); +} +double Sub::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + return f_->compute_lipschitz(r, u, t0, t1) + + g_->compute_lipschitz(r, u, t0, t1); +} +std::pair Sub::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [f_min, f_max] = f_->compute_f_min_max(r, u, t0, t1); + auto [g_min, g_max] = g_->compute_f_min_max(r, u, t0, t1); + return {f_min - g_max, f_max - g_min}; +} +pugi::xml_node Sub::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("sub"); + f_->to_xml_element(node, cache_map); + g_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Mul +//============================================================================== + +std::string Mul::expression() const +{ + return f_->expression() + " * " + g_->expression(); +} +double Mul::evaluate(Position r) const +{ + return f_->evaluate(r) * g_->evaluate(r); +} +Gradient Mul::gradient(Position r) const +{ + double fv = f_->evaluate(r); + double gv = g_->evaluate(r); + return gv * f_->gradient(r) + fv * g_->gradient(r); +} +double Mul::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + double lipschitz_f = f_->compute_lipschitz(r, u, t0, t1); + double lipschitz_g = g_->compute_lipschitz(r, u, t0, t1); + auto [f_min, f_max] = f_->compute_f_min_max(r, u, t0, t1); + auto [g_min, g_max] = g_->compute_f_min_max(r, u, t0, t1); + double max_abs_f = std::max(std::abs(f_min), std::abs(f_max)); + double max_abs_g = std::max(std::abs(g_min), std::abs(g_max)); + return max_abs_f * lipschitz_g + max_abs_g * lipschitz_f; +} +std::pair Mul::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [f_min, f_max] = f_->compute_f_min_max(r, u, t0, t1); + auto [g_min, g_max] = g_->compute_f_min_max(r, u, t0, t1); + auto [p_min, p_max] = interval_mul(f_min, f_max, g_min, g_max); + return {p_min, p_max}; +} +pugi::xml_node Mul::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("mul"); + f_->to_xml_element(node, cache_map); + g_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Div +//============================================================================== + +std::string Div::expression() const +{ + return f_->expression() + " / " + g_->expression(); +} +double Div::evaluate(Position r) const +{ + double denom = g_->evaluate(r); + if (denom == 0.0) + throw std::domain_error("Implicit Div: zero denominator at r=(" + + std::to_string(r.x) + ", " + std::to_string(r.y) + + ", " + std::to_string(r.z) + ") in expression " + + g_->expression()); + return f_->evaluate(r) / denom; +} +Gradient Div::gradient(Position r) const +{ + double fv = f_->evaluate(r); + double gv = g_->evaluate(r); + Gradient gf = f_->gradient(r); + Gradient gg = g_->gradient(r); + if (gv == 0.0) { + throw std::domain_error("Div::gradient: argument equals zero at r=(" + + std::to_string(r.x) + ", " + std::to_string(r.y) + + ", " + std::to_string(r.z) + ") in expression " + + g_->expression()); + } + double inv_g2 = 1.0 / (gv * gv); + return (gf * gv - fv * gg) * inv_g2; +} +double Div::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + double lipschitz_f = f_->compute_lipschitz(r, u, t0, t1); + double lipschitz_g = g_->compute_lipschitz(r, u, t0, t1); + auto [f_min, f_max] = f_->compute_f_min_max(r, u, t0, t1); + auto [g_min, g_max] = g_->compute_f_min_max(r, u, t0, t1); + double min_abs_g = std::min(std::abs(g_min), std::abs(g_max)); + double max_abs_f = std::max(std::abs(f_min), std::abs(f_max)); + double max_abs_g = std::max(std::abs(g_min), std::abs(g_max)); + if (min_abs_g > 0.0) { + return (lipschitz_f * max_abs_g + max_abs_f * lipschitz_g) / + (min_abs_g * min_abs_g); + } else { + throw std::domain_error( + "Implicit Div: zero denominator on ray between r=(" + + std::to_string(r.x + t0 * u.x) + ", " + std::to_string(r.y + t0 * u.y) + + ", " + std::to_string(r.z + t0 * u.z) + ") and r=(" + + std::to_string(r.x + t1 * u.x) + ", " + std::to_string(r.y + t1 * u.y) + + ", " + std::to_string(r.z + t1 * u.z) + ") in expression " + + g_->expression()); + } +} +std::pair Div::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [f_min, f_max] = f_->compute_f_min_max(r, u, t0, t1); + auto [g_min, g_max] = g_->compute_f_min_max(r, u, t0, t1); + double inv_min, inv_max; + if (g_min > 0.0) { + // g does not contain zero - compute 1/[g_min, g_max] + inv_min = 1.0 / g_max; + inv_max = 1.0 / g_min; + } else if (g_max < 0.0) { + // g does not contain zero - compute 1/[g_min, g_max] + inv_min = 1.0 / g_min; + inv_max = 1.0 / g_max; + } else { + // g straddles zero - not a lipschitz function. + throw std::domain_error( + "Implicit Div: zero denominator on ray between r=(" + + std::to_string(r.x + t0 * u.x) + ", " + std::to_string(r.y + t0 * u.y) + + ", " + std::to_string(r.z + t0 * u.z) + ") and r=(" + + std::to_string(r.x + t1 * u.x) + ", " + std::to_string(r.y + t1 * u.y) + + ", " + std::to_string(r.z + t1 * u.z) + ") in expression " + + g_->expression()); + } + auto [p_min, p_max] = interval_mul(f_min, f_max, inv_min, inv_max); + return {p_min, p_max}; +} +pugi::xml_node Div::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("div"); + f_->to_xml_element(node, cache_map); + g_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Scale +//============================================================================== + +std::string Scale::expression() const +{ + return std::to_string(k_) + " * " + f_->expression(); +} +double Scale::evaluate(Position r) const +{ + return k_ * f_->evaluate(r); +} +Gradient Scale::gradient(Position r) const +{ + return k_ * f_->gradient(r); +} +double Scale::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + return std::abs(k_) * f_->compute_lipschitz(r, u, t0, t1); +} +std::pair Scale::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [f_min, f_max] = f_->compute_f_min_max(r, u, t0, t1); + if (k_ >= 0.0) { + return {k_ * f_min, k_ * f_max}; + } else { + return {k_ * f_max, k_ * f_min}; + } +} +pugi::xml_node Scale::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("scale"); + node.append_attribute("value") = k_; + f_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Neg +//============================================================================== + +std::string Neg::expression() const +{ + return "-" + f_->expression(); +} +double Neg::evaluate(Position r) const +{ + return -f_->evaluate(r); +} +Gradient Neg::gradient(Position r) const +{ + return -f_->gradient(r); +} +double Neg::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + return f_->compute_lipschitz(r, u, t0, t1); +} +std::pair Neg::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [f_min, f_max] = f_->compute_f_min_max(r, u, t0, t1); + return {-f_max, -f_min}; +} +pugi::xml_node Neg::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("neg"); + f_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Pow +//============================================================================== + +std::string Pow::expression() const +{ + return f_->expression() + " ** " + std::to_string(exp_); +} +double Pow::evaluate(Position r) const +{ + return std::pow(f_->evaluate(r), exp_); +} +Gradient Pow::gradient(Position r) const +{ + // Chain rule: (f^n)' = n * f^(n-1) * f' + double fv = f_->evaluate(r); + Gradient gf = f_->gradient(r); + double coeff = exp_ * std::pow(fv, exp_ - 1.0); + return coeff * gf; +} +double Pow::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + double lipschitz_f = f_->compute_lipschitz(r, u, t0, t1); + auto [f_min, f_max] = f_->compute_f_min_max(r, u, t0, t1); + double max_abs_f = std::max(std::abs(f_min), std::abs(f_max)); + return std::abs(exp_) * std::pow(max_abs_f, exp_ - 1.0) * lipschitz_f; +} +std::pair Pow::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [f_min, f_max] = f_->compute_f_min_max(r, u, t0, t1); + if (exp_ % 2 != 0) { + // Odd exponent: monotone increasing everywhere + return {std::pow(f_min, exp_), std::pow(f_max, exp_)}; + } else { + // Even exponent: minimum at 0 if interval straddles 0 + double p_min = std::pow(f_min, exp_); + double p_max = std::pow(f_max, exp_); + if (f_min <= 0.0 && f_max >= 0.0) + return {0.0, std::max(p_min, p_max)}; + else + return {std::min(p_min, p_max), std::max(p_min, p_max)}; + } +} +pugi::xml_node Pow::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("pow"); + node.append_attribute("value") = exp_; + f_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Sin +//============================================================================== + +std::string Sin::expression() const +{ + return "Sin(" + arg_->expression() + ")"; +} +double Sin::evaluate(Position r) const +{ + return std::sin(arg_->evaluate(r)); +} +Gradient Sin::gradient(Position r) const +{ + double c = std::cos(arg_->evaluate(r)); + Gradient g = arg_->gradient(r); + return c * g; +} +double Sin::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + return arg_->compute_lipschitz(r, u, t0, t1); +} +std::pair Sin::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [arg_min, arg_max] = arg_->compute_f_min_max(r, u, t0, t1); + double sin_arg_min = std::sin(arg_min); + double sin_arg_max = std::sin(arg_max); + double f_min = std::min(sin_arg_min, sin_arg_max); + double f_max = std::max(sin_arg_min, sin_arg_max); + + // sin reaches +1 at π/2 + 2kπ + double period_arg_min = std::ceil((arg_min - M_PI / 2.0) / (2.0 * M_PI)); + double period_arg_max = std::floor((arg_max - M_PI / 2.0) / (2.0 * M_PI)); + if (period_arg_min <= period_arg_max) + f_max = 1.0; + + // sin reaches -1 at -π/2 + 2kπ + period_arg_min = std::ceil((arg_min + M_PI / 2.0) / (2.0 * M_PI)); + period_arg_max = std::floor((arg_max + M_PI / 2.0) / (2.0 * M_PI)); + if (period_arg_min <= period_arg_max) + f_min = -1.0; + + return {f_min, f_max}; +} +pugi::xml_node Sin::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("sin"); + arg_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Cos +//============================================================================== + +std::string Cos::expression() const +{ + return "Cos(" + arg_->expression() + ")"; +} +double Cos::evaluate(Position r) const +{ + return std::cos(arg_->evaluate(r)); +} +Gradient Cos::gradient(Position r) const +{ + double c = -std::sin(arg_->evaluate(r)); + Gradient g = arg_->gradient(r); + return c * g; +} +double Cos::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + return arg_->compute_lipschitz(r, u, t0, t1); +} +std::pair Cos::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [arg_min, arg_max] = arg_->compute_f_min_max(r, u, t0, t1); + double cos_arg_min = std::cos(arg_min); + double cos_arg_max = std::cos(arg_max); + double f_min = std::min(cos_arg_min, cos_arg_max); + double f_max = std::max(cos_arg_min, cos_arg_max); + + // cos reaches +1 at 2kπ + double period_arg_min = std::ceil(arg_min / (2.0 * M_PI)); + double period_arg_max = std::floor(arg_max / (2.0 * M_PI)); + if (period_arg_min <= period_arg_max) + f_max = 1.0; + + // cos reaches -1 at π + 2kπ + period_arg_min = std::ceil((arg_min - M_PI) / (2.0 * M_PI)); + period_arg_max = std::floor((arg_max - M_PI) / (2.0 * M_PI)); + if (period_arg_min <= period_arg_max) + f_min = -1.0; + + return {f_min, f_max}; +} +pugi::xml_node Cos::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("cos"); + arg_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Sqrt +//============================================================================== + +std::string Sqrt::expression() const +{ + return "Sqrt(" + arg_->expression() + ")"; +} +double Sqrt::evaluate(Position r) const +{ + return std::sqrt(arg_->evaluate(r)); +} +Gradient Sqrt::gradient(Position r) const +{ + double argv = arg_->evaluate(r); + if (argv <= 0.0) { + throw std::domain_error( + "Sqrt::gradient: argument equals zero or negative r=(" + + std::to_string(r.x) + ", " + std::to_string(r.y) + ", " + + std::to_string(r.z) + ") in expression " + arg_->expression()); + } + double c = 0.5 / std::sqrt(argv); + Gradient g = arg_->gradient(r); + return c * g; +} +double Sqrt::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + // L(√f) = L(f) / (2 * √f_min) — derivative 1/(2√f) is largest at f_min + double lipschitz_arg = arg_->compute_lipschitz(r, u, t0, t1); + auto [arg_min, arg_max] = arg_->compute_f_min_max(r, u, t0, t1); + if (arg_min <= 0.0) + throw std::domain_error( + "Sqrt::compute_lipschitz: argument reaches zero or negative on ray " + "between r=(" + + std::to_string(r.x + t0 * u.x) + ", " + std::to_string(r.y + t0 * u.y) + + ", " + std::to_string(r.z + t0 * u.z) + ") and r=(" + + std::to_string(r.x + t1 * u.x) + ", " + std::to_string(r.y + t1 * u.y) + + ", " + std::to_string(r.z + t1 * u.z) + ") in expression " + + arg_->expression()); + return lipschitz_arg / (2.0 * std::sqrt(arg_min)); +} +std::pair Sqrt::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [arg_min, arg_max] = arg_->compute_f_min_max(r, u, t0, t1); + if (arg_min < 0.0) + throw std::domain_error( + "Sqrt::compute_f_min_max: argument is negative on ray between r=(" + + std::to_string(r.x + t0 * u.x) + ", " + std::to_string(r.y + t0 * u.y) + + ", " + std::to_string(r.z + t0 * u.z) + ") and r=(" + + std::to_string(r.x + t1 * u.x) + ", " + std::to_string(r.y + t1 * u.y) + + ", " + std::to_string(r.z + t1 * u.z) + ") in expression " + + arg_->expression()); + return {std::sqrt(arg_min), std::sqrt(arg_max)}; +} +pugi::xml_node Sqrt::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("sqrt"); + arg_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Exp +//============================================================================== + +std::string Exp::expression() const +{ + return "Exp(" + arg_->expression() + ")"; +} +double Exp::evaluate(Position r) const +{ + return std::exp(arg_->evaluate(r)); +} +Gradient Exp::gradient(Position r) const +{ + double c = std::exp(arg_->evaluate(r)); + Gradient g = arg_->gradient(r); + return c * g; +} +double Exp::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + double lipschitz_arg = arg_->compute_lipschitz(r, u, t0, t1); + auto [arg_min, arg_max] = arg_->compute_f_min_max(r, u, t0, t1); + return lipschitz_arg * std::exp(arg_max); +} +std::pair Exp::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [arg_min, arg_max] = arg_->compute_f_min_max(r, u, t0, t1); + return {std::exp(arg_min), std::exp(arg_max)}; +} +pugi::xml_node Exp::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("exp"); + arg_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Log +//============================================================================== + +std::string Log::expression() const +{ + return "Log(" + arg_->expression() + ")"; +} +double Log::evaluate(Position r) const +{ + return std::log(arg_->evaluate(r)); +} +Gradient Log::gradient(Position r) const +{ + double argv = arg_->evaluate(r); + if (argv <= 0.0) { + throw std::domain_error( + "Log::gradient: argument equals zero or negative r=(" + + std::to_string(r.x) + ", " + std::to_string(r.y) + ", " + + std::to_string(r.z) + ") in expression " + arg_->expression()); + } + double c = 1 / argv; + Gradient g = arg_->gradient(r); + return c * g; +} +double Log::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + double lipschitz_arg = arg_->compute_lipschitz(r, u, t0, t1); + auto [arg_min, arg_max] = arg_->compute_f_min_max(r, u, t0, t1); + if (arg_min <= 0.0) + throw std::domain_error( + "Log::compute_lipschitz: argument reaches zero or negative on ray " + "between r=(" + + std::to_string(r.x + t0 * u.x) + ", " + std::to_string(r.y + t0 * u.y) + + ", " + std::to_string(r.z + t0 * u.z) + ") and r=(" + + std::to_string(r.x + t1 * u.x) + ", " + std::to_string(r.y + t1 * u.y) + + ", " + std::to_string(r.z + t1 * u.z) + ") in expression " + + arg_->expression()); + return lipschitz_arg / arg_min; +} +std::pair Log::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [arg_min, arg_max] = arg_->compute_f_min_max(r, u, t0, t1); + if (arg_min <= 0.0) + throw std::domain_error( + "Log::compute_f_min_max: argument is zero or negative on ray between " + "r=(" + + std::to_string(r.x + t0 * u.x) + ", " + std::to_string(r.y + t0 * u.y) + + ", " + std::to_string(r.z + t0 * u.z) + ") and r=(" + + std::to_string(r.x + t1 * u.x) + ", " + std::to_string(r.y + t1 * u.y) + + ", " + std::to_string(r.z + t1 * u.z) + ") in expression " + + arg_->expression()); + return {std::log(arg_min), std::log(arg_max)}; +} +pugi::xml_node Log::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("log"); + arg_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Abs +//============================================================================== + +std::string Abs::expression() const +{ + return "|" + arg_->expression() + "|"; +} +double Abs::evaluate(Position r) const +{ + return std::abs(arg_->evaluate(r)); +} +Gradient Abs::gradient(Position r) const +{ + double c = std::copysign(1.0, arg_->evaluate(r)); + Gradient g = arg_->gradient(r); + return c * g; +} +double Abs::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + return arg_->compute_lipschitz(r, u, t0, t1); +} +std::pair Abs::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [arg_min, arg_max] = arg_->compute_f_min_max(r, u, t0, t1); + if (arg_min >= 0.0) + return {arg_min, arg_max}; // entirely non-negative + else if (arg_max <= 0.0) + return {-arg_max, -arg_min}; // entirely non-positive + else + return {0.0, std::max(-arg_min, arg_max)}; // straddles zero +} +pugi::xml_node Abs::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("abs"); + arg_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Cached +//============================================================================== + +void Cached::refresh(Position r) const +{ + auto& entry = step_cache.node_cache[this]; // creates default entry if absent + + if (entry.epoch != step_cache.epoch || r.x != entry.pos.x || + r.y != entry.pos.y || r.z != entry.pos.z) { + entry.val = child_->evaluate(r); + entry.grad = child_->gradient(r); + entry.epoch = step_cache.epoch; + entry.pos = r; + } +} +std::string Cached::expression() const +{ + return "@[" + child_->expression() + "]"; +} +double Cached::evaluate(Position r) const +{ + refresh(r); + return step_cache.node_cache.at(this).val; +} +Gradient Cached::gradient(Position r) const +{ + refresh(r); + return step_cache.node_cache.at(this).grad; +} +double Cached::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + return child_->compute_lipschitz(r, u, t0, t1); +} +std::pair Cached::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + return child_->compute_f_min_max(r, u, t0, t1); +} +pugi::xml_node Cached::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto it = cache_map.find(this); + if (it != cache_map.end()) { + // Already emitted — write a back-reference + auto node = parent.append_child("from_cache"); + node.append_attribute("id") = it->second; + return node; + } + // First visit — register and emit the full subtree + int id = static_cast(cache_map.size()); + cache_map[this] = id; + auto node = parent.append_child("to_cache"); + node.append_attribute("id") = id; + child_->to_xml_element(node, cache_map); + return node; +} + +} // namespace implicit +} // namespace openmc \ No newline at end of file diff --git a/src/implicit_solvers.cpp b/src/implicit_solvers.cpp new file mode 100644 index 00000000000..5871c17ef27 --- /dev/null +++ b/src/implicit_solvers.cpp @@ -0,0 +1,80 @@ +#include "openmc/implicit_solvers.h" + +#include + +#include "openmc/constants.h" +#include "openmc/error.h" + +namespace openmc { + +double NaiveLipschitz::solve(const Implicit& function, Position r, Direction u, + double t0, double t1, double isovalue) const +{ + const double L = function.compute_lipschitz(r, u, t0, t1); + + // Constant function — root only if already within tolerance at t0. + if (L == 0.0) { + double f0 = function.along_ray(t0, r, u) - isovalue; + return (std::abs(f0) < atol_) ? t0 : INFTY; + } + + double t_curr = t0; + double f_curr = function.along_ray(t_curr, r, u) - isovalue; + + for (int i = 0; i < max_iter_; ++i) { + // Safe Lipschitz step: f can only change by L*step over this distance, + // so no root is possible before t_curr + |f_curr| / L. + const double step = std::abs(f_curr) / L; + const double t_next = t_curr + step; + + // Stepped past the end of the interval — check endpoint. + if (t_next >= t1) { + double f1 = function.along_ray(t1, r, u) - isovalue; + if (f_curr * f1 < 0.0) + return bisect(function, r, u, t_curr, t1, f_curr, f1, isovalue); + return INFTY; + } + + const double f_next = function.along_ray(t_next, r, u) - isovalue; + + if (std::abs(f_next) < L * atol_) + return t_next; + + // Sign change detected — root is bracketed in [t_curr, t_next]. + if (f_curr * f_next < 0.0) + return bisect(function, r, u, t_curr, t_next, f_curr, f_next, isovalue); + + t_curr = t_next; + f_curr = f_next; + } + + // Max iterations reached — skip this surface. + warning(fmt::format("NaiveLipschitz reached max iterations ({})." + " The surface will be skipped. Results could be biased.", + max_iter_)); + return INFTY; +} + +double NaiveLipschitz::bisect(const Implicit& function, Position r, Direction u, + double ta, double tb, double fa, double fb, double isovalue) const +{ + // Invariant: fa and fb have opposite signs, root is in (ta, tb). + // tb is always on the destination side of the crossing. + // We return tb rather than the midpoint so that the returned position + // is unambiguously on the destination side — this prevents sense() + // from placing the particle back in the cell it just left. + while ((tb - ta) > atol_) { + const double tm = 0.5 * (ta + tb); + const double fm = function.along_ray(tm, r, u) - isovalue; + if (fa * fm < 0.0) { + tb = tm; + fb = fm; + } else { + ta = tm; + fa = fm; + } + } + return tb; +} + +} // namespace openmc \ No newline at end of file diff --git a/src/surface.cpp b/src/surface.cpp index 81b756deae7..ddf5f6e530d 100644 --- a/src/surface.cpp +++ b/src/surface.cpp @@ -167,8 +167,10 @@ void Surface::to_hdf5(hid_t group_id) const if (geom_type() == GeometryType::DAG) { write_string(surf_group, "geom_type", "dagmc", false); - } else if (geom_type() == GeometryType::CSG) { - write_string(surf_group, "geom_type", "csg", false); + } else if (geom_type() == GeometryType::CSG || + geom_type() == GeometryType::IMP) { + write_string(surf_group, "geom_type", + geom_type() == GeometryType::IMP ? "imp" : "csg", false); if (bc_) { write_string(surf_group, "boundary_type", bc_->type(), false); @@ -1168,6 +1170,73 @@ Direction SurfaceZTorus::normal(Position r) const return n / n.norm(); } +//============================================================================== +// SurfaceImplicit implementation +//============================================================================== + +SurfaceImplicit::SurfaceImplicit(pugi::xml_node surf_node) : Surface(surf_node) +{ + read_coeffs(surf_node, id_, + {&x0_, &y0_, &z0_, &A_, &B_, &C_, &D_, &E_, &F_, &G_, &H_, &I_}); + isovalue_ = surf_node.attribute("isovalue").as_double(); + auto func_node = surf_node.child("function"); + if (!func_node) + fatal_error(fmt::format("Surface {} missing element.", id_)); + function_ = Implicit::from_xml_element(func_node.first_child()); + solver_ = std::make_unique(); +} +void SurfaceImplicit::to_hdf5_inner(hid_t group_id) const +{ + write_string(group_id, "type", "implicit", false); + std::array coeffs { + {x0_, y0_, z0_, A_, B_, C_, D_, E_, F_, G_, H_, I_}}; + write_dataset(group_id, "coefficients", coeffs); + write_dataset(group_id, "isovalue", isovalue_); + write_string(group_id, "function_xml", function_->to_xml_string(), false); +} +Position SurfaceImplicit::transform(Position r) const +{ + double dx = r.x - x0_, dy = r.y - y0_, dz = r.z - z0_; + return Position(A_ * dx + B_ * dy + C_ * dz, D_ * dx + E_ * dy + F_ * dz, + G_ * dx + H_ * dy + I_ * dz); +} +Direction SurfaceImplicit::transform_dir(Direction u) const +{ + // Rotation only — no translation for directions + return Direction(A_ * u.x + B_ * u.y + C_ * u.z, + D_ * u.x + E_ * u.y + F_ * u.z, G_ * u.x + H_ * u.y + I_ * u.z); +} +double SurfaceImplicit::evaluate(Position r) const +{ + return function_->evaluate(transform(r)) - isovalue_; +} +double SurfaceImplicit::distance_finite( + Position r, Direction u, bool coincident, double distance_max) const +{ + double f0 = function_->evaluate(r); + double t0 = (std::abs(f0) <= FP_COINCIDENT || coincident) + ? 10.0 * solver_->get_atol() + : 0.0; + Position r_tr = transform(r); + Direction u_tr = transform_dir(u); + return solver_->solve(*function_, r_tr, u_tr, t0, distance_max, isovalue_) + + 10.0 * solver_->get_atol(); +} +double SurfaceImplicit::distance(Position r, Direction u, bool coincident) const +{ + fatal_error("SurfaceImplicit::distance: shouldn't be called."); +} +Direction SurfaceImplicit::normal(Position r) const +{ + Position r_tr = transform(r); + Gradient g = function_->gradient(r_tr); + double nx = A_ * g.x + D_ * g.y + G_ * g.z; + double ny = B_ * g.x + E_ * g.y + H_ * g.z; + double nz = C_ * g.x + F_ * g.y + I_ * g.z; + double len = std::sqrt(nx * nx + ny * ny + nz * nz); + return {nx / len, ny / len, nz / len}; +} + //============================================================================== void read_surfaces(pugi::xml_node node, @@ -1188,7 +1257,7 @@ void read_surfaces(pugi::xml_node node, pugi::xml_node surf_node; int i_surf; for (surf_node = node.child("surface"), i_surf = 0; surf_node; - surf_node = surf_node.next_sibling("surface"), i_surf++) { + surf_node = surf_node.next_sibling("surface"), i_surf++) { std::string surf_type = get_node_value(surf_node, "type", true, true); // Allocate and initialize the new surface @@ -1238,6 +1307,9 @@ void read_surfaces(pugi::xml_node node, } else if (surf_type == "z-torus") { model::surfaces.push_back(std::make_unique(surf_node)); + } else if (surf_type == "implicit") { + model::surfaces.push_back(std::make_unique(surf_node)); + } else { fatal_error(fmt::format("Invalid surface type, \"{}\"", surf_type)); } diff --git a/tests/regression_tests/implicit/__init__.py b/tests/regression_tests/implicit/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/regression_tests/implicit/gyroid/__init__.py b/tests/regression_tests/implicit/gyroid/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/regression_tests/implicit/gyroid/inputs_true.dat b/tests/regression_tests/implicit/gyroid/inputs_true.dat new file mode 100644 index 00000000000..6f1b96b1ac5 --- /dev/null +++ b/tests/regression_tests/implicit/gyroid/inputs_true.dat @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+ + +
+ + + + +
+
+
+
+ + + +
+ + + + +
+
+
+ + + +
+
+ + + + + + + + +
+
+
+
+ + eigenvalue + 1000 + 20 + 5 + + + 0.0 0.0 0.0 + + + +
diff --git a/tests/regression_tests/implicit/gyroid/results_true.dat b/tests/regression_tests/implicit/gyroid/results_true.dat new file mode 100644 index 00000000000..94892fa737b --- /dev/null +++ b/tests/regression_tests/implicit/gyroid/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +1.147570E-01 8.639323E-04 diff --git a/tests/regression_tests/implicit/gyroid/test.py b/tests/regression_tests/implicit/gyroid/test.py new file mode 100644 index 00000000000..22927c127c3 --- /dev/null +++ b/tests/regression_tests/implicit/gyroid/test.py @@ -0,0 +1,42 @@ +import openmc +import pytest + +from openmc.surface import ImplicitSurface +from openmc.implicit import X, Y, Z +from tests.testing_harness import PyAPITestHarness + + +@pytest.fixture() +def implicit_sphere_model(): + # Material + material = openmc.Material() + material.add_nuclide('U235', 1.0) + material.set_density('g/cm3', 16.0) + + # Geometry: implicit sphere enclosed in an analytic vacuum sphere. + # The outer analytic sphere provides the finite region required by + # the two-pass Region::distance algorithm. + R = 2.0 + outer = openmc.Sphere(r=R, boundary_type='vacuum') + impl = openmc.TPMS.from_pitch_isovalue("gyroid", 1., 0.) + + fuel_cell = openmc.Cell(region=-impl & -outer, fill=material) + void_cell = openmc.Cell(region=+impl & -outer) + geometry = openmc.Geometry([fuel_cell, void_cell]) + + # Settings + settings = openmc.Settings() + settings.particles = 1000 + settings.batches = 20 + settings.inactive = 5 + + model = openmc.Model(settings=settings, geometry=geometry) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Point((0., 0., 0.)), + ) + return model + + +def test_implicit_sphere(implicit_sphere_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) + harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/sphere/__init__.py b/tests/regression_tests/implicit/sphere/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/regression_tests/implicit/sphere/inputs_true.dat b/tests/regression_tests/implicit/sphere/inputs_true.dat new file mode 100644 index 00000000000..b27c3fc8b5c --- /dev/null +++ b/tests/regression_tests/implicit/sphere/inputs_true.dat @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + eigenvalue + 1000 + 20 + 5 + + + 0.0 0.0 0.0 + + + true + + + + diff --git a/tests/regression_tests/implicit/sphere/results_true.dat b/tests/regression_tests/implicit/sphere/results_true.dat new file mode 100644 index 00000000000..18b45d12b24 --- /dev/null +++ b/tests/regression_tests/implicit/sphere/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +1.009527E+00 7.071045E-03 diff --git a/tests/regression_tests/implicit/sphere/test.py b/tests/regression_tests/implicit/sphere/test.py new file mode 100644 index 00000000000..c46abf48a61 --- /dev/null +++ b/tests/regression_tests/implicit/sphere/test.py @@ -0,0 +1,43 @@ +import openmc +import pytest + +from openmc.surface import ImplicitSurface +from openmc.implicit import X, Y, Z +from tests.testing_harness import PyAPITestHarness + + +@pytest.fixture() +def implicit_sphere_model(): + # Material + material = openmc.Material() + material.add_nuclide('U235', 1.0) + material.set_density('g/cm3', 16.0) + + # Geometry: implicit sphere enclosed in an analytic vacuum sphere. + # The outer analytic sphere provides the finite region required by + # the two-pass Region::distance algorithm. + R = 10.0 + outer = openmc.Sphere(r=R * 1.1, boundary_type='vacuum') + impl = ImplicitSurface(function=X()**2 + Y()**2 + Z()**2, isovalue=R**2) + + fuel_cell = openmc.Cell(region=-impl & -outer, fill=material) + void_cell = openmc.Cell(region=+impl & -outer) + geometry = openmc.Geometry([fuel_cell, void_cell]) + + # Settings + settings = openmc.Settings() + settings.particles = 1000 + settings.batches = 20 + settings.inactive = 5 + + model = openmc.Model(settings=settings, geometry=geometry) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Point((0., 0., 0.)), + constraints={'fissionable': True}, + ) + return model + + +def test_implicit_sphere(implicit_sphere_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) + harness.main() \ No newline at end of file diff --git a/tests/unit_tests/test_implicit.py b/tests/unit_tests/test_implicit.py index 835d5411955..fc7cd37c907 100644 --- a/tests/unit_tests/test_implicit.py +++ b/tests/unit_tests/test_implicit.py @@ -44,7 +44,6 @@ def test_div(): assert Div(Y(), X()).evaluate(PT) == 2.0 def test_scale(): assert Scale(X(), 3.0).evaluate(PT) == 3.0 def test_neg(): assert Neg(X()).evaluate(PT) == -1.0 def test_pow_int(): assert Pow(Y(), 2).evaluate(PT) == 4.0 -def test_pow_float(): assert Pow(Y(), 0.5).evaluate(PT) == pytest.approx(np.sqrt(2.0)) # ── transcendentals ──────────────────────────────────────────────────────── @@ -72,6 +71,10 @@ def test_log_negative(): with pytest.raises(ValueError, match="negative"): Log(Constant(-1.0)).evaluate(PT) +def test_pow_float(): + with pytest.raises(TypeError): + X() ** 2.0 + def test_pow_non_scalar_via_operator(): with pytest.raises(TypeError): X() ** Y() @@ -130,7 +133,7 @@ def test_scale_value_attr(): assert float(Scale(X(), 2.5).to_xml_element([]).get("value")) == pytest.approx(2.5) def test_pow_value_attr(): - assert float(Pow(X(), 3.0).to_xml_element([]).get("value")) == pytest.approx(3.0) + assert float(Pow(X(), 3).to_xml_element([]).get("value")) == pytest.approx(3.0) def test_binary_node_has_two_children(): for binary in [Add, Sub, Mul, Div]: @@ -142,7 +145,7 @@ def test_unary_node_has_one_child(): elem = unary(X()).to_xml_element([]) assert len(elem) == 1 for unary in [Scale, Pow]: - elem = unary(X(), 1.).to_xml_element([]) + elem = unary(X(), 1).to_xml_element([]) assert len(elem) == 1 def test_wrong_tag(): diff --git a/tests/unit_tests/test_implicit_surface.py b/tests/unit_tests/test_implicit_surface.py new file mode 100644 index 00000000000..e607eca20bd --- /dev/null +++ b/tests/unit_tests/test_implicit_surface.py @@ -0,0 +1,523 @@ +""" +Tests for openmc.ImplicitSurface and openmc.TPMS. + +Run with: pytest test_implicit_surface.py -v +""" + +import warnings + +import numpy as np +import pytest + +import openmc.implicit as impl +from openmc.implicit import ( + X, Y, Z, + Add, Mul, Scale, Sin, Cos, Cached, + ImplicitFunction, +) +from openmc.surface import ImplicitSurface, TPMS + + +# ============================================================================== +# Construction +# ============================================================================== + + +def test_valid_minimal(): + """Only the function is required; all other params default correctly.""" + func = X()**2 + Y()**2 + Z()**2 + surf = ImplicitSurface(function=func) + assert surf.isovalue == 0.0 + assert surf.x0 == 0.0 + assert surf.y0 == 0.0 + assert surf.z0 == 0.0 + assert np.allclose(surf.get_rotation_matrix(), np.eye(3)) + +def test_valid_full_params(): + """All twelve transform parameters plus isovalue are stored.""" + surf = ImplicitSurface( + function=X(), isovalue=3.14, + x0=1., y0=2., z0=3., + a=1., b=0., c=0., + d=0., e=1., f=0., + g=0., h=0., i=1., + ) + assert surf.isovalue == pytest.approx(3.14) + assert surf.x0 == pytest.approx(1.) + assert surf.y0 == pytest.approx(2.) + assert surf.z0 == pytest.approx(3.) + +def test_isovalue_stored_as_float(): + """isovalue is always stored as float even when passed as int.""" + surf = ImplicitSurface(function=X(), isovalue=5) + assert isinstance(surf.isovalue, float) + +def test_default_rotation_is_identity(): + """Default a-i coefficients encode the 3x3 identity.""" + surf = ImplicitSurface(function=X()) + assert np.allclose(surf.get_rotation_matrix(), np.eye(3)) + +def test_invalid_boundary_vacuum(): + """Vacuum boundary type raises ValueError.""" + with pytest.raises(ValueError, match="transmission"): + ImplicitSurface(function=X(), boundary_type='vacuum') + +def test_invalid_boundary_reflective(): + """Reflective boundary type raises ValueError.""" + with pytest.raises(ValueError, match="transmission"): + ImplicitSurface(function=X(), boundary_type='reflective') + +def test_invalid_rotation_non_orthogonal(): + """Non-orthogonal a-i matrix raises ValueError.""" + with pytest.raises(ValueError, match="rotation"): + ImplicitSurface(function=X(), + a=2., b=0., c=0., + d=0., e=1., f=0., + g=0., h=0., i=1.) + +def test_invalid_rotation_det_minus_one(): + """Orthogonal matrix with det = -1 (reflection) raises ValueError.""" + with pytest.raises(ValueError, match="rotation"): + ImplicitSurface(function=X(), + a=-1., b=0., c=0., + d=0., e=1., f=0., + g=0., h=0., i=1.) + +def test_invalid_function_type_string(): + """Passing a string as function raises TypeError.""" + with pytest.raises(TypeError): + ImplicitSurface(function="not a function") + +def test_invalid_function_type_none(): + """Passing None as function raises TypeError.""" + with pytest.raises(TypeError): + ImplicitSurface(function=None) + +def test_repr_does_not_crash(): + """repr() returns a non-empty string that mentions Function and Isovalue.""" + surf = ImplicitSurface(function=X()**2 + Y()**2 + Z()**2, isovalue=25.) + s = repr(surf) + assert len(s) > 0 + assert "Function" in s + assert "Isovalue" in s + + +# ============================================================================== +# evaluate +# ============================================================================== + +# Sphere: f = x^2 + y^2 + z^2, isovalue = R^2 +R = 5.0 + +@pytest.fixture +def sphere(): + func = X()**2 + Y()**2 + Z()**2 + return ImplicitSurface(function=func, isovalue=R**2) + +def test_interior_negative(sphere): + """Origin is inside the sphere → evaluate < 0.""" + assert sphere.evaluate((0., 0., 0.)) < 0. + +def test_exterior_positive(sphere): + """Point clearly outside sphere → evaluate > 0.""" + assert sphere.evaluate((10., 0., 0.)) > 0. + +def test_on_surface_zero(sphere): + """Point exactly on sphere surface → evaluate ≈ 0.""" + assert sphere.evaluate((R, 0., 0.)) == pytest.approx(0., abs=1e-10) + assert sphere.evaluate((0., R, 0.)) == pytest.approx(0., abs=1e-10) + assert sphere.evaluate((0., 0., R)) == pytest.approx(0., abs=1e-10) + +def test_isovalue_shifts_zero_level(): + """Larger isovalue makes previously-exterior points interior.""" + func = X()**2 + Y()**2 + Z()**2 + s_small = ImplicitSurface(function=func, isovalue=25.) # r = 5 + s_large = ImplicitSurface(function=func, isovalue=100.) # r = 10 + pt = (7., 0., 0.) # between the two radii + assert s_small.evaluate(pt) > 0. + assert s_large.evaluate(pt) < 0. + +def test_translation_shifts_surface(): + """x0 = 5 moves the sphere centre to (5, 0, 0).""" + func = X()**2 + Y()**2 + Z()**2 + surf = ImplicitSurface(function=func, isovalue=16., x0=5.) + assert surf.evaluate((9., 0., 0.)) == pytest.approx(0., abs=1e-10) # on surface + assert surf.evaluate((5., 0., 0.)) < 0. # centre → inside + assert surf.evaluate((5., 0., 0.)) == pytest.approx(-16., abs=1e-10) # centre value + assert surf.evaluate((0., 0., 0.)) > 0. # outside + +def test_rotation_applied_correctly(): + """90° z-rotation of the x-plane (f = x) makes f = y in world coords. + + Rotation matrix [[0,-1,0],[1,0,0],[0,0,1]] maps r_local.x = -y, + so evaluate(r) = X().evaluate(r_local) = -y. + """ + surf = ImplicitSurface(function=X(), isovalue=0.) + rotated = surf.rotate([0., 0., 90.]) + assert rotated.evaluate((0., 1., 0.)) == pytest.approx( 1., abs=1e-10) + assert rotated.evaluate((0., -1., 0.)) == pytest.approx(-1., abs=1e-10) + assert rotated.evaluate((5., 0., 0.)) == pytest.approx( 0., abs=1e-10) # on surface + +def test_matrix_rotation_applied_correctly(): + """Same as precedent but with a matrix""" + surf = ImplicitSurface(function=X(), isovalue=0.) + rmat = [[0,-1,0],[1,0,0],[0,0,1]] + rotated = surf.rotate(rmat) + assert rotated.evaluate((0., 1., 0.)) == pytest.approx( 1., abs=1e-10) + assert rotated.evaluate((0., -1., 0.)) == pytest.approx(-1., abs=1e-10) + assert rotated.evaluate((5., 0., 0.)) == pytest.approx( 0., abs=1e-10) # on surface + +# ============================================================================== +# translate +# ============================================================================== + +def test_updates_origin_components(): + """translate([1, 2, 3]) increments x0, y0, z0 correctly.""" + surf = ImplicitSurface(function=X()) + t = surf.translate([1., 2., 3.]) + assert t.x0 == pytest.approx(1.) + assert t.y0 == pytest.approx(2.) + assert t.z0 == pytest.approx(3.) + +def test_returns_new_object_by_default(): + """Default inplace=False returns a different Python object.""" + surf = ImplicitSurface(function=X()) + t = surf.translate([1., 0., 0.]) + assert t is not surf + +def test_original_unchanged(): + """Original surface is not modified when inplace=False.""" + surf = ImplicitSurface(function=X()) + surf.translate([5., 0., 0.]) + assert surf.x0 == pytest.approx(0.) + +def test_inplace_modifies_same_object(): + """inplace=True modifies and returns the same object.""" + surf = ImplicitSurface(function=X()) + result = surf.translate([3., 0., 0.], inplace=True) + assert result is surf + assert surf.x0 == pytest.approx(3.) + +def test_zero_vector_returns_clone(): + """translate([0,0,0]) returns a clone (new object), not self. + + This differs from the base-class behaviour where the same object is + returned for zero translations. + """ + surf = ImplicitSurface(function=X()) + result = surf.translate([0., 0., 0.]) + assert result is not surf + +def test_preserves_function_reference(): + """translate does not copy or modify the ImplicitFunction DAG.""" + func = X()**2 + Y()**2 + Z()**2 + surf = ImplicitSurface(function=func) + t = surf.translate([1., 0., 0.]) + assert t.function is func + +def test_evaluate_consistency(): + """After translate([5,0,0]), sphere centre moves accordingly.""" + func = X()**2 + Y()**2 + Z()**2 + surf = ImplicitSurface(function=func, isovalue=16.) + t = surf.translate([5., 0., 0.]) + assert t.evaluate((9., 0., 0.)) == pytest.approx(0., abs=1e-10) # on surface + assert t.evaluate((5., 0., 0.)) == pytest.approx(-16., abs=1e-10) # centre + assert t.evaluate((0., 0., 0.)) > 0. # outside + + +# ============================================================================== +# rotate +# ============================================================================== + +def test_euler_angles_update_rotation_matrix(): + """rotate([0, 0, 90]) applies Rz(90°) to the rotation matrix.""" + surf = ImplicitSurface(function=X()) + rotated = surf.rotate([0., 0., 90.]) + R90z = np.array([[0., -1., 0.], [1., 0., 0.], [0., 0., 1.]]) + expected = R90z.T + assert np.allclose(rotated.get_rotation_matrix(), expected, atol=1e-10) + +def test_rotation_matrix_passed_directly(): + """A 3x3 ndarray can be passed directly as the rotation.""" + R90z = np.array([[0., -1., 0.], [1., 0., 0.], [0., 0., 1.]]) + surf = ImplicitSurface(function=X()) + rotated = surf.rotate(R90z) + assert np.allclose(rotated.get_rotation_matrix(), R90z.T, atol=1e-10) + +@pytest.mark.parametrize("angles", [ + [30., 0., 0.], [0., 45., 0.], [0., 0., 90.], + [30., 45., 60.], [15., -30., 75.], +]) +def test_result_is_always_valid_rotation(angles): + """Rotated surface always has an orthogonal matrix with det = +1.""" + surf = ImplicitSurface(function=X()) + rotated = surf.rotate(angles) + R = rotated.get_rotation_matrix() + assert np.allclose(R @ R.T, np.eye(3), atol=1e-10), \ + f"Not orthogonal for angles {angles}" + assert np.isclose(np.linalg.det(R), 1.0, atol=1e-10), \ + f"det ≠ 1 for angles {angles}" + +def test_preserves_function_reference(): + """rotate does not copy or modify the ImplicitFunction DAG.""" + func = X() + surf = ImplicitSurface(function=func) + assert surf.rotate([0., 0., 45.]).function is func + +def test_pivot_translates_origin(): + """Rotating about a non-origin pivot moves the surface origin correctly. + + Sphere centred at (5,0,0); 90° z-rotation about origin → centre at (0,5,0). + """ + func = X()**2 + Y()**2 + Z()**2 + surf = ImplicitSurface(function=func, isovalue=4., x0=5.) + rotated = surf.rotate([0., 0., 90.], pivot=(0., 0., 0.)) + assert rotated.x0 == pytest.approx(0., abs=1e-10) + assert rotated.y0 == pytest.approx(5., abs=1e-10) + assert rotated.z0 == pytest.approx(0., abs=1e-10) + + +# ============================================================================== +# is_equal and clone +# ============================================================================== + + +def test_equal_to_itself(): + func = X()**2 + Y()**2 + Z()**2 + surf = ImplicitSurface(function=func, isovalue=25.) + assert surf.is_equal(surf) + +def test_equal_same_function_object_same_params(): + """Two surfaces sharing the same Python function object and params.""" + func = X() + s1 = ImplicitSurface(function=func, isovalue=1.) + s2 = ImplicitSurface(function=func, isovalue=1.) + assert s1.is_equal(s2) + +def test_not_equal_different_isovalue(): + func = X() + s1 = ImplicitSurface(function=func, isovalue=1.) + s2 = ImplicitSurface(function=func, isovalue=2.) + assert not s1.is_equal(s2) + +def test_not_equal_different_translation(): + func = X() + s1 = ImplicitSurface(function=func, x0=0.) + s2 = ImplicitSurface(function=func, x0=1.) + assert not s1.is_equal(s2) + +def test_not_equal_different_function_objects(): + """is_equal uses Python object identity for the function — two + equivalent but distinct objects are NOT equal.""" + s1 = ImplicitSurface(function=X()) + s2 = ImplicitSurface(function=X()) + assert not s1.is_equal(s2) + +def test_clone_has_new_id(): + surf = ImplicitSurface(function=X()) + clone = surf.clone() + assert clone.id != surf.id + +def test_clone_shares_function_reference(): + """clone preserves the Python function object, not a deep copy.""" + func = X()**2 + Y()**2 + Z()**2 + surf = ImplicitSurface(function=func, isovalue=25.) + assert surf.clone().function is func + +def test_clone_independent_coefficients(): + """Translating a clone does not affect the original.""" + surf = ImplicitSurface(function=X()) + clone = surf.clone() + clone.translate([5., 0., 0.], inplace=True) + assert surf.x0 == pytest.approx(0.) + + +# ============================================================================== +# bounding_box +# ============================================================================== + +def test_always_infinite(): + """bounding_box returns an infinite box on both sides — no analytical + bound is available without a user-supplied bbox.""" + surf = ImplicitSurface(function=X()**2 + Y()**2 + Z()**2, isovalue=25.) + bb_pos = surf.bounding_box('+') + bb_neg = surf.bounding_box('-') + assert np.all(bb_pos.lower_left == -np.inf) + assert np.all(bb_neg.lower_left == -np.inf) + assert np.all(bb_pos.upper_right == +np.inf) + assert np.all(bb_neg.upper_right == +np.inf) + + +# ============================================================================== +# XML round-trip +# ============================================================================== + +def test_roundtrip_basic(): + """to_xml_element + from_xml_element produces a surface that evaluates + identically.""" + func = X()**2 + Y()**2 + Z()**2 + surf = ImplicitSurface(function=func, isovalue=25.) + elem = surf.to_xml_element() + restored = ImplicitSurface.from_xml_element(elem) + for pt in [(3., 4., 0.), (0., 0., 0.), (5., 0., 0.)]: + assert restored.evaluate(pt) == pytest.approx(surf.evaluate(pt), abs=1e-10) + +def test_roundtrip_isovalue_preserved(): + """isovalue survives the XML round-trip exactly.""" + surf = ImplicitSurface(function=X(), isovalue=3.14) + restored = ImplicitSurface.from_xml_element(surf.to_xml_element()) + assert restored.isovalue == pytest.approx(3.14) + +def test_roundtrip_transform_params_preserved(): + """All 12 transform coefficients survive the round-trip.""" + surf = ImplicitSurface(function=X(), isovalue=0., + x0=1., y0=2., z0=3., + a=1., b=0., c=0., + d=0., e=1., f=0., + g=0., h=0., i=1.) + restored = ImplicitSurface.from_xml_element(surf.to_xml_element()) + assert np.allclose(restored._get_base_coeffs(), + surf._get_base_coeffs(), atol=1e-10) + +def test_roundtrip_with_cached_nodes(): + """Surface with Cached nodes evaluates identically after round-trip.""" + cx = Cached(2. * X()) + surf = ImplicitSurface(function=Sin(cx) * Cos(cx), isovalue=0.) + restored = ImplicitSurface.from_xml_element(surf.to_xml_element()) + for pt in [(0., 0., 0.), (0.5, 0., 0.), (1., 0., 0.)]: + assert restored.evaluate(pt) == pytest.approx(surf.evaluate(pt), abs=1e-10) + +def test_roundtrip_produces_implicit_surface_not_tpms(): + """from_xml_element always produces ImplicitSurface (not TPMS) because + TPMS is not a direct subclass of Surface and is absent from + _SURFACE_CLASSES.""" + surf = TPMS.from_pitch_isovalue("gyroid", 1.0, 0.0) + restored = ImplicitSurface.from_xml_element(surf.to_xml_element()) + assert type(restored) is ImplicitSurface + +def test_single_use_cached_warns(): + """Cached node used only once triggers a UserWarning during serialisation.""" + surf = ImplicitSurface(function=Sin(Cached(X()))) + with pytest.warns(UserWarning, match="only used once"): + surf.to_xml_element() + +def test_reused_cached_no_warning(): + """Cached node used twice (shared) triggers no UserWarning.""" + cx = Cached(X()) + surf = ImplicitSurface(function=Sin(cx) + Cos(cx)) + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + surf.to_xml_element() # must not raise + + +# ============================================================================== +# TPMS +# ============================================================================== + +# ── construction ────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("name", [ + "primitive", "schwarz_p", + "gyroid", "schoen-g", + "diamond", "schwarz_d", +]) +def test_valid_names_produce_implicit_surface(name): + assert isinstance(TPMS.from_pitch_isovalue(name, 1.0, 0.0), ImplicitSurface) + +def test_unknown_name_raises(): + """Unknown TPMS name raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + TPMS.from_pitch_isovalue("screugneugneu", 1.0, 0.0) + +def test_isovalue_stored_correctly(): + surf = TPMS.from_pitch_isovalue("primitive", 1.0, 0.5) + assert surf.isovalue == pytest.approx(0.5) + +# ── primitive (Schwartz-P) ──────────────────────────────────────────────── + +def test_primitive_at_origin(): + """cos(0)+cos(0)+cos(0) = 3.0, isovalue=0 → evaluate = 3.""" + surf = TPMS.from_pitch_isovalue("primitive", 1.0, 0.0) + assert surf.evaluate((0., 0., 0.)) == pytest.approx(3.0, abs=1e-10) + +def test_primitive_periodicity(): + """Primitive is periodic with pitch L along each axis.""" + L = 1.0 + surf = TPMS.from_pitch_isovalue("primitive", L, 0.0) + for pt in [(0.1, 0.2, 0.3), (0.3, 0.4, 0.1)]: + v_orig = surf.evaluate(pt) + v_shift_x = surf.evaluate((pt[0] + L, pt[1], pt[2])) + v_shift_y = surf.evaluate((pt[0], pt[1] + L, pt[2])) + v_shift_z = surf.evaluate((pt[0], pt[1], pt[2] + L)) + assert v_orig == pytest.approx(v_shift_x, abs=1e-10) + assert v_orig == pytest.approx(v_shift_y, abs=1e-10) + assert v_orig == pytest.approx(v_shift_z, abs=1e-10) + +def test_pitch_scales_spatial_frequency(): + """Doubling the pitch halves the spatial frequency: + evaluate_2L(x, 0, 0) == evaluate_L(x/2, 0, 0).""" + s1 = TPMS.from_pitch_isovalue("primitive", 1.0, 0.0) + s2 = TPMS.from_pitch_isovalue("primitive", 2.0, 0.0) + for x in [0.1, 0.2, 0.35]: + assert s2.evaluate((x, 0., 0.)) == pytest.approx( + s1.evaluate((x / 2., 0., 0.)), abs=1e-10) + +def test_isovalue_shifts_evaluate(): + """Increasing isovalue by Δ decreases evaluate by Δ at every point.""" + s0 = TPMS.from_pitch_isovalue("primitive", 1.0, 0.0) + s1 = TPMS.from_pitch_isovalue("primitive", 1.0, 1.0) + for pt in [(0., 0., 0.), (0.1, 0.2, 0.3)]: + assert s0.evaluate(pt) - s1.evaluate(pt) == pytest.approx(1.0, abs=1e-10) + +# ── gyroid ──────────────────────────────────────────────────────────────── + +def test_gyroid_at_origin(): + """Gyroid f = sin·cos + sin·cos + sin·cos = 0 at the origin.""" + surf = TPMS.from_pitch_isovalue("gyroid", 1.0, 0.0) + assert surf.evaluate((0., 0., 0.)) == pytest.approx(0., abs=1e-10) + +def test_gyroid_at_quarter_pitch(): + """Gyroid at (L/4, 0, 0) should be 1.0. + + Analytical: + x_scaled = 2π*(L/4)/L = π/2, y_scaled = 0, z_scaled = 0 + sin(π/2)*cos(0) + sin(0)*cos(π/2) + sin(0)*cos(0) = 1 + 0 + 0 = 1. + """ + L = 1.0 + surf = TPMS.from_pitch_isovalue("gyroid", L, 0.0) + assert surf.evaluate((L / 4., 0., 0.)) == pytest.approx(1.0, abs=1e-10) + +def test_gyroid_periodicity(): + """Gyroid is periodic with pitch L along x.""" + L = 1.0 + surf = TPMS.from_pitch_isovalue("gyroid", L, 0.0) + for pt in [(0.1, 0.2, 0.3), (0.4, 0.1, 0.5)]: + assert surf.evaluate(pt) == pytest.approx( + surf.evaluate((pt[0] + L, pt[1], pt[2])), abs=1e-10) + +# ── diamond —────────────────────────────────────────────────────────────── + +def test_diamond_at_origin(): + """Diamond f = sin(x)*cos(y-z) + sin(y+z)*cos(x) = 0 at the origin.""" + surf = TPMS.from_pitch_isovalue("diamond", 1.0, 0.0) + assert surf.evaluate((0., 0., 0.)) == pytest.approx(0., abs=1e-10) + +def test_diamond_at_eighth_pitch(): + """Diamond at (L/8, 0, 0) should equal sin(π/4) = √2/2. + + Analytical: + x_scaled = 2π*(L/8)/L = π/4, y_scaled = 0, z_scaled = 0 + sin(π/4)*cos(0-0) + sin(0+0)*cos(π/4) = sin(π/4) + 0 = √2/2. + """ + L = 1.0 + surf = TPMS.from_pitch_isovalue("diamond", L, 0.0) + expected = np.sin(np.pi / 4.) # ≈ 0.7071 + assert surf.evaluate((L / 8., 0., 0.)) == pytest.approx(expected, abs=1e-10) + +def test_diamond_periodicity(): + """Diamond is periodic with pitch L along x.""" + L = 1.0 + surf = TPMS.from_pitch_isovalue("diamond", L, 0.0) + for pt in [(0.1, 0.2, 0.3), (0.4, 0.1, 0.5)]: + assert surf.evaluate(pt) == pytest.approx( + surf.evaluate((pt[0] + L, pt[1], pt[2])), abs=1e-10) \ No newline at end of file From edca6191e293bf7e4a4d84cea91b134975e57779 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Mon, 1 Jun 2026 19:25:55 -0600 Subject: [PATCH 05/22] Added implicit settings --- include/openmc/implicit_solvers.h | 15 ++++- include/openmc/settings.h | 7 +++ openmc/settings.py | 56 ++++++++++++++++++ src/implicit_solvers.cpp | 97 ++++++++++++++++++++++++++++++- src/settings.cpp | 28 +++++++++ src/surface.cpp | 10 ++-- 6 files changed, 205 insertions(+), 8 deletions(-) diff --git a/include/openmc/implicit_solvers.h b/include/openmc/implicit_solvers.h index 72ca608b6b4..fa6f346a7cf 100644 --- a/include/openmc/implicit_solvers.h +++ b/include/openmc/implicit_solvers.h @@ -20,8 +20,8 @@ namespace openmc { class ImplicitSolver { public: - ImplicitSolver(double atol = 1e-8, int max_iter = 1000000) - : atol_(atol), max_iter_(max_iter) + ImplicitSolver(double atol = 1e-8, double ftol = 1e-7, int max_iter = 1000000) + : atol_(atol), ftol_(ftol), max_iter_(max_iter) {} virtual ~ImplicitSolver() = default; @@ -32,8 +32,12 @@ class ImplicitSolver { // access atol outside the solver double get_atol() const { return atol_; } + static std::unique_ptr create( + const std::string& name, int max_iter, double atol, double ftol); + protected: double atol_; + double ftol_; int max_iter_; }; @@ -69,5 +73,12 @@ class NaiveLipschitz : public ImplicitSolver { double tb, double fa, double fb, double isovalue) const; }; +class FastLipschitz : public ImplicitSolver { +public: + using ImplicitSolver::ImplicitSolver; + double solve(const Implicit& function, Position r, Direction u, double t0, + double t1, double isovalue) const override; +}; + } // namespace openmc #endif // OPENMC_IMPLICIT_SOLVER_H \ No newline at end of file diff --git a/include/openmc/settings.h b/include/openmc/settings.h index 3bba040f5d7..237bfa5c0dd 100644 --- a/include/openmc/settings.h +++ b/include/openmc/settings.h @@ -204,6 +204,13 @@ extern "C" int verbosity; //!< How verbose to make output extern double weight_cutoff; //!< Weight cutoff for Russian roulette extern double weight_survive; //!< Survival weight after Russian roulette +// implicit solvers +extern int implicit_max_iter; +extern std::string implicit_solver; +extern double implicit_atol; +extern double implicit_ftol; +extern double implicit_margin; + } // namespace settings //============================================================================== diff --git a/openmc/settings.py b/openmc/settings.py index 8120eb073e6..ba9d687307f 100644 --- a/openmc/settings.py +++ b/openmc/settings.py @@ -500,6 +500,7 @@ def __init__(self, **kwargs): self._use_decay_photons = None self._random_ray = {} + self._implicit = {} for key, value in kwargs.items(): setattr(self, key, value) @@ -1491,6 +1492,36 @@ def free_gas_threshold(self, free_gas_threshold: float | None): cv.check_greater_than('free gas threshold', free_gas_threshold, 0.0) self._free_gas_threshold = free_gas_threshold + @property + def implicit(self) -> dict: + return self._implicit + + @implicit.setter + def implicit(self, implicit: dict): + if not isinstance(implicit, Mapping): + raise ValueError(f'Unable to set implicit from "{implicit}" ' + 'which is not a dict.') + for key, value in implicit.items(): + if key == 'maxiter': + cv.check_type('maxiter', value, Integral) + cv.check_greater_than('maxiter', value, 0) + elif key == 'atol': + cv.check_type('atol', value, Real) + cv.check_greater_than('atol', value, 0.0) + elif key == 'ftol': + cv.check_type('ftol', value, Real) + cv.check_greater_than('ftol', value, 0.0) + elif key == 'name': + cv.check_type('name', value, str) + elif key == 'margin': + cv.check_type('margin', value, Real) + cv.check_greater_than('margin', value, 0.0, True) + else: + raise ValueError(f'Unable to set implicit to "{key}" which is ' + 'unsupported by OpenMC') + + self._implicit = implicit + def _create_run_mode_subelement(self, root): elem = ET.SubElement(root, "run_mode") elem.text = self._run_mode.value @@ -2062,6 +2093,13 @@ def _create_free_gas_threshold_subelement(self, root): element = ET.SubElement(root, "free_gas_threshold") element.text = str(self._free_gas_threshold) + def _create_implicit_solvers_subelement(self, root): + if self._implicit: + element = ET.SubElement(root, "implicit") + for key, value in self._implicit.items(): + subelement = ET.SubElement(element, key) + subelement.text = str(value) + def _eigenvalue_from_xml_element(self, root): elem = root.find('eigenvalue') if elem is not None: @@ -2560,6 +2598,22 @@ def _free_gas_threshold_from_xml_element(self, root): if text is not None: self.free_gas_threshold = float(text) + def _implicit_solvers_from_xml_element(self, root): + elem = root.find('implicit') + if elem is not None: + self.implicit = {} + + elem = root.find('implicit') + if elem is not None: + self.implicit = {} + for child in elem: + if child.tag in ('atol', 'ftol', 'margin'): + self.implicit[child.tag] = float(child.text) + elif child.tag == 'max_iter': + self.implicit[child.tag] = int(child.text) + elif child.tag == 'name': + self.implicit[child.tag] = str(child.text) + def to_xml_element(self, mesh_memo=None): """Create a 'settings' element to be written to an XML file. @@ -2637,6 +2691,7 @@ def to_xml_element(self, mesh_memo=None): self._create_use_decay_photons_subelement(element) self._create_source_rejection_fraction_subelement(element) self._create_free_gas_threshold_subelement(element) + self._create_implicit_solvers_subelement(element) # Clean the indentation in the file to be user-readable clean_indentation(element) @@ -2755,6 +2810,7 @@ def from_xml_element(cls, elem, meshes=None): settings._use_decay_photons_from_xml_element(elem) settings._source_rejection_fraction_from_xml_element(elem) settings._free_gas_threshold_from_xml_element(elem) + settings._implicit_solvers_from_xml_element(elem) return settings diff --git a/src/implicit_solvers.cpp b/src/implicit_solvers.cpp index 5871c17ef27..7e6d21cc22d 100644 --- a/src/implicit_solvers.cpp +++ b/src/implicit_solvers.cpp @@ -7,6 +7,20 @@ namespace openmc { +std::unique_ptr ImplicitSolver::create( + const std::string& name, int max_iter, double atol, double ftol) +{ + if (name == "naive") + return std::make_unique(atol, ftol, max_iter); + if (name == "fast") + return std::make_unique(atol, ftol, max_iter); + + fatal_error("make_implicit_solver: unknown solver name '" + name + + "'. " + "Valid options are: 'naive', 'simple', 'fast'."); + return nullptr; // unreachable — suppresses compiler warning +} + double NaiveLipschitz::solve(const Implicit& function, Position r, Direction u, double t0, double t1, double isovalue) const { @@ -15,7 +29,7 @@ double NaiveLipschitz::solve(const Implicit& function, Position r, Direction u, // Constant function — root only if already within tolerance at t0. if (L == 0.0) { double f0 = function.along_ray(t0, r, u) - isovalue; - return (std::abs(f0) < atol_) ? t0 : INFTY; + return (std::abs(f0) < ftol_) ? t0 : INFTY; } double t_curr = t0; @@ -37,7 +51,7 @@ double NaiveLipschitz::solve(const Implicit& function, Position r, Direction u, const double f_next = function.along_ray(t_next, r, u) - isovalue; - if (std::abs(f_next) < L * atol_) + if (std::abs(f_next) < ftol_) return t_next; // Sign change detected — root is bracketed in [t_curr, t_next]. @@ -77,4 +91,83 @@ double NaiveLipschitz::bisect(const Implicit& function, Position r, Direction u, return tb; } +double FastLipschitz::solve(const Implicit& function, Position r, Direction u, + double t0, double t1, double isovalue) const +{ + // Stack of intervals to explore. Stored as (t0, t1) pairs. + // Because the stack is LIFO, pushing the right half first ensures + // the left half (smaller t) is always explored first, so the + // solver finds the smallest root. + std::vector> stack; + stack.reserve(64); + stack.push_back({t0, t1}); + + int n_iter = 0; + + while (!stack.empty()) { + auto [t0, t1] = stack.back(); + stack.pop_back(); + + // Interval has collapsed to within tolerance — root is at t0. + if (t1 - t0 < atol_) + return t1; + + // Compute tight Lipschitz bound and function range over [t0, t1]. + const double L = function.compute_lipschitz(r, u, t0, t1); + + if (L == 0.0) { + // Constant on this interval — root only if already on surface. + double f0 = function.along_ray(t0, r, u) - isovalue; + if (std::abs(f0) < atol_) + return t0; + continue; + } + + auto [fmin_raw, fmax_raw] = function.compute_f_min_max(r, u, t0, t1); + const double fmin = fmin_raw - isovalue; + const double fmax = fmax_raw - isovalue; + + // Both bounds have the same sign — no root in this interval. + if (fmin * fmax > 0.0) + continue; + + // Endpoint values (needed to compute safe sub-interval). + const double f0 = function.along_ray(t0, r, u) - isovalue; + const double f1 = function.along_ray(t1, r, u) - isovalue; + + // Safe start: root cannot exist before tSafeStart. + // Derivation: |f(t)| >= |f0| - L*(t-t0), so f cannot cross zero + // until |f0| - L*(t-t0) <= 0 => t >= t0 + |f0|/L. + // Subtract L*atol_ to account for the tolerance band. + const double tSafeStart = t0 + std::max(0.0, std::abs(f0) - L * atol_) / L; + if (tSafeStart - t0 < atol_) + return tSafeStart; // root is within atol of t0 + + // Safe end: root cannot exist after tSafeEnd (symmetric argument). + const double tSafeEnd = t1 - std::max(0.0, std::abs(f1) - L * atol_) / L; + if (t1 - tSafeEnd < atol_) + return t1; // root is within atol of t1 + + // Safe interval has collapsed — root is at tSafeStart. + if (tSafeEnd - tSafeStart < atol_) + return tSafeEnd; + + if (++n_iter >= max_iter_) { + warning( + fmt::format("FastLipschitz reached max iterations ({})." + " The surface will be skipped. Results could be biased.", + max_iter_)); + return INFTY; + } + + // Split the safe interval and push both halves. + // Right is pushed first so that left is popped and explored first. + const double tSplit = 0.5 * (tSafeStart + tSafeEnd); + stack.push_back({tSplit, tSafeEnd}); // right — explored second + stack.push_back({tSafeStart, tSplit}); // left — explored first + } + + return INFTY; +} + } // namespace openmc \ No newline at end of file diff --git a/src/settings.cpp b/src/settings.cpp index 8ae252ae1ab..c4c22048d22 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -154,6 +154,13 @@ int verbosity {-1}; double weight_cutoff {0.25}; double weight_survive {1.0}; +// implicit surface +int implicit_max_iter {1000000}; +std::string implicit_solver {"fast"}; +double implicit_atol {1.e-8}; +double implicit_ftol {1.e-7}; +double implicit_margin {1.e-7}; + } // namespace settings //============================================================================== @@ -1342,6 +1349,27 @@ void read_settings_xml(pugi::xml_node root) settings::use_shared_secondary_bank = true; } } + // Implicit surfaces settings + if (check_for_node(root, "implicit")) { + xml_node implicit_node = root.child("implicit"); + if (check_for_node(implicit_node, "max_iter")) { + implicit_max_iter = std::stoi(get_node_value(implicit_node, "max_iter")); + } + if (check_for_node(implicit_node, "name")) { + implicit_solver = get_node_value(implicit_node, "name"); + if (implicit_solver != "naive" && implicit_solver != "fast") + fatal_error("Unrecognized implicit solver name: " + implicit_solver); + } + if (check_for_node(implicit_node, "atol")) { + implicit_atol = std::stod(get_node_value(implicit_node, "atol")); + } + if (check_for_node(implicit_node, "ftol")) { + implicit_ftol = std::stod(get_node_value(implicit_node, "ftol")); + } + if (check_for_node(implicit_node, "margin")) { + implicit_margin = std::stod(get_node_value(implicit_node, "margin")); + } + } } void free_memory_settings() diff --git a/src/surface.cpp b/src/surface.cpp index ddf5f6e530d..23b6ad43edd 100644 --- a/src/surface.cpp +++ b/src/surface.cpp @@ -1183,7 +1183,9 @@ SurfaceImplicit::SurfaceImplicit(pugi::xml_node surf_node) : Surface(surf_node) if (!func_node) fatal_error(fmt::format("Surface {} missing element.", id_)); function_ = Implicit::from_xml_element(func_node.first_child()); - solver_ = std::make_unique(); + solver_ = ImplicitSolver::create(settings::implicit_solver, + settings::implicit_max_iter, settings::implicit_atol, + settings::implicit_ftol); } void SurfaceImplicit::to_hdf5_inner(hid_t group_id) const { @@ -1214,13 +1216,13 @@ double SurfaceImplicit::distance_finite( Position r, Direction u, bool coincident, double distance_max) const { double f0 = function_->evaluate(r); - double t0 = (std::abs(f0) <= FP_COINCIDENT || coincident) - ? 10.0 * solver_->get_atol() + double t0 = (std::abs(f0) <= settings::implicit_ftol || coincident) + ? settings::implicit_margin : 0.0; Position r_tr = transform(r); Direction u_tr = transform_dir(u); return solver_->solve(*function_, r_tr, u_tr, t0, distance_max, isovalue_) + - 10.0 * solver_->get_atol(); + settings::implicit_margin; } double SurfaceImplicit::distance(Position r, Direction u, bool coincident) const { From 59185a5249c75da78a4d14d393fc40b7d8e2249f Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Thu, 4 Jun 2026 19:56:19 -0600 Subject: [PATCH 06/22] Added regression tests and completed unit tests on settings, surface and implicit --- .../implicit/diamond/__init__.py | 0 .../implicit/diamond/inputs_true.dat | 86 +++++++ .../implicit/diamond/results_true.dat | 2 + .../regression_tests/implicit/diamond/test.py | 50 ++++ .../implicit/gyroid/inputs_true.dat | 22 +- .../implicit/gyroid/results_true.dat | 2 +- .../regression_tests/implicit/gyroid/test.py | 24 +- .../implicit/primitive/__init__.py | 0 .../implicit/primitive/inputs_true.dat | 69 ++++++ .../implicit/primitive/results_true.dat | 2 + .../implicit/primitive/test.py | 50 ++++ .../implicit/sphere/inputs_true.dat | 9 +- .../implicit/sphere/results_true.dat | 2 +- .../regression_tests/implicit/sphere/test.py | 2 +- .../implicit/sphere_classic/__init__.py | 0 .../implicit/sphere_classic/inputs_true.dat | 32 +++ .../implicit/sphere_classic/results_true.dat | 2 + .../implicit/sphere_classic/test.py | 39 ++++ tests/unit_tests/settings.xml | 7 + tests/unit_tests/test_implicit.py | 21 +- tests/unit_tests/test_implicit_surface.py | 40 ++++ tests/unit_tests/test_settings_implicit.py | 213 ++++++++++++++++++ 22 files changed, 648 insertions(+), 26 deletions(-) create mode 100644 tests/regression_tests/implicit/diamond/__init__.py create mode 100644 tests/regression_tests/implicit/diamond/inputs_true.dat create mode 100644 tests/regression_tests/implicit/diamond/results_true.dat create mode 100644 tests/regression_tests/implicit/diamond/test.py create mode 100644 tests/regression_tests/implicit/primitive/__init__.py create mode 100644 tests/regression_tests/implicit/primitive/inputs_true.dat create mode 100644 tests/regression_tests/implicit/primitive/results_true.dat create mode 100644 tests/regression_tests/implicit/primitive/test.py create mode 100644 tests/regression_tests/implicit/sphere_classic/__init__.py create mode 100644 tests/regression_tests/implicit/sphere_classic/inputs_true.dat create mode 100644 tests/regression_tests/implicit/sphere_classic/results_true.dat create mode 100644 tests/regression_tests/implicit/sphere_classic/test.py create mode 100644 tests/unit_tests/settings.xml create mode 100644 tests/unit_tests/test_settings_implicit.py diff --git a/tests/regression_tests/implicit/diamond/__init__.py b/tests/regression_tests/implicit/diamond/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/regression_tests/implicit/diamond/inputs_true.dat b/tests/regression_tests/implicit/diamond/inputs_true.dat new file mode 100644 index 00000000000..2ee9bc20edc --- /dev/null +++ b/tests/regression_tests/implicit/diamond/inputs_true.dat @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+
+ + + +
+ + + + +
+
+ +
+ + + + +
+
+
+
+
+ + + + + + + + + + + +
+
+
+
+ + eigenvalue + 1000 + 20 + 5 + + + 0.0 0.0 0.0 + + + + fast + 1e-10 + 1e-10 + 5e-09 + + +
diff --git a/tests/regression_tests/implicit/diamond/results_true.dat b/tests/regression_tests/implicit/diamond/results_true.dat new file mode 100644 index 00000000000..575e0958e01 --- /dev/null +++ b/tests/regression_tests/implicit/diamond/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +9.372485E-01 4.644406E-03 diff --git a/tests/regression_tests/implicit/diamond/test.py b/tests/regression_tests/implicit/diamond/test.py new file mode 100644 index 00000000000..4ea5a255051 --- /dev/null +++ b/tests/regression_tests/implicit/diamond/test.py @@ -0,0 +1,50 @@ +import openmc +import pytest + +from openmc.surface import ImplicitSurface +from openmc.implicit import X, Y, Z +from tests.testing_harness import PyAPITestHarness + + +@pytest.fixture() +def implicit_sphere_model(): + # Material + material = openmc.Material() + material.add_nuclide('U235', 5.0) + material.add_nuclide('U238', 95.0) + material.set_density('g/cm3', 16.0) + + # Gyroid + x0 = openmc.XPlane(-0.5, boundary_type="periodic") + x1 = openmc.XPlane(+0.5, boundary_type="periodic") + y0 = openmc.YPlane(-0.5, boundary_type="periodic") + y1 = openmc.YPlane(+0.5, boundary_type="periodic") + z0 = openmc.ZPlane(-0.5, boundary_type="periodic") + z1 = openmc.ZPlane(+0.5, boundary_type="periodic") + x0.periodic_surface = x1 + y0.periodic_surface = y1 + z0.periodic_surface = z1 + box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 + impl = openmc.TPMS.from_pitch_isovalue("diamond", 1., 0.) + + fuel_cell = openmc.Cell(region=-impl & box, fill=material) + void_cell = openmc.Cell(region=+impl & box) + geometry = openmc.Geometry([fuel_cell, void_cell]) + + # Settings + settings = openmc.Settings() + settings.particles = 1000 + settings.batches = 20 + settings.inactive = 5 + settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} + + model = openmc.Model(settings=settings, geometry=geometry) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Point((0., 0., 0.)), + ) + return model + + +def test_implicit_sphere(implicit_sphere_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) + harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/gyroid/inputs_true.dat b/tests/regression_tests/implicit/gyroid/inputs_true.dat index 6f1b96b1ac5..5b0a5936efd 100644 --- a/tests/regression_tests/implicit/gyroid/inputs_true.dat +++ b/tests/regression_tests/implicit/gyroid/inputs_true.dat @@ -3,14 +3,20 @@ - + + - - - - + + + + + + + + + @@ -74,5 +80,11 @@ 0.0 0.0 0.0 + + fast + 1e-10 + 1e-10 + 5e-09 + diff --git a/tests/regression_tests/implicit/gyroid/results_true.dat b/tests/regression_tests/implicit/gyroid/results_true.dat index 94892fa737b..08676c4e9d2 100644 --- a/tests/regression_tests/implicit/gyroid/results_true.dat +++ b/tests/regression_tests/implicit/gyroid/results_true.dat @@ -1,2 +1,2 @@ k-combined: -1.147570E-01 8.639323E-04 +9.455384E-01 6.846333E-03 diff --git a/tests/regression_tests/implicit/gyroid/test.py b/tests/regression_tests/implicit/gyroid/test.py index 22927c127c3..a6b1e40864d 100644 --- a/tests/regression_tests/implicit/gyroid/test.py +++ b/tests/regression_tests/implicit/gyroid/test.py @@ -10,18 +10,25 @@ def implicit_sphere_model(): # Material material = openmc.Material() - material.add_nuclide('U235', 1.0) + material.add_nuclide('U235', 5.0) + material.add_nuclide('U238', 95.0) material.set_density('g/cm3', 16.0) - # Geometry: implicit sphere enclosed in an analytic vacuum sphere. - # The outer analytic sphere provides the finite region required by - # the two-pass Region::distance algorithm. - R = 2.0 - outer = openmc.Sphere(r=R, boundary_type='vacuum') + # Gyroid + x0 = openmc.XPlane(-0.5, boundary_type="periodic") + x1 = openmc.XPlane(+0.5, boundary_type="periodic") + y0 = openmc.YPlane(-0.5, boundary_type="periodic") + y1 = openmc.YPlane(+0.5, boundary_type="periodic") + z0 = openmc.ZPlane(-0.5, boundary_type="periodic") + z1 = openmc.ZPlane(+0.5, boundary_type="periodic") + x0.periodic_surface = x1 + y0.periodic_surface = y1 + z0.periodic_surface = z1 + box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 impl = openmc.TPMS.from_pitch_isovalue("gyroid", 1., 0.) - fuel_cell = openmc.Cell(region=-impl & -outer, fill=material) - void_cell = openmc.Cell(region=+impl & -outer) + fuel_cell = openmc.Cell(region=-impl & box, fill=material) + void_cell = openmc.Cell(region=+impl & box) geometry = openmc.Geometry([fuel_cell, void_cell]) # Settings @@ -29,6 +36,7 @@ def implicit_sphere_model(): settings.particles = 1000 settings.batches = 20 settings.inactive = 5 + settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} model = openmc.Model(settings=settings, geometry=geometry) model.settings.source = openmc.IndependentSource( diff --git a/tests/regression_tests/implicit/primitive/__init__.py b/tests/regression_tests/implicit/primitive/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/regression_tests/implicit/primitive/inputs_true.dat b/tests/regression_tests/implicit/primitive/inputs_true.dat new file mode 100644 index 00000000000..f504bb6935b --- /dev/null +++ b/tests/regression_tests/implicit/primitive/inputs_true.dat @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+
+ +
+ + + + +
+
+
+ +
+ + + + +
+
+
+
+
+
+ + eigenvalue + 1000 + 20 + 5 + + + 0.0 0.0 0.0 + + + + fast + 1e-10 + 1e-10 + 5e-09 + + +
diff --git a/tests/regression_tests/implicit/primitive/results_true.dat b/tests/regression_tests/implicit/primitive/results_true.dat new file mode 100644 index 00000000000..e0958c913b7 --- /dev/null +++ b/tests/regression_tests/implicit/primitive/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +9.489407E-01 5.099686E-03 diff --git a/tests/regression_tests/implicit/primitive/test.py b/tests/regression_tests/implicit/primitive/test.py new file mode 100644 index 00000000000..d496e4a5ff9 --- /dev/null +++ b/tests/regression_tests/implicit/primitive/test.py @@ -0,0 +1,50 @@ +import openmc +import pytest + +from openmc.surface import ImplicitSurface +from openmc.implicit import X, Y, Z +from tests.testing_harness import PyAPITestHarness + + +@pytest.fixture() +def implicit_sphere_model(): + # Material + material = openmc.Material() + material.add_nuclide('U235', 5.0) + material.add_nuclide('U238', 95.0) + material.set_density('g/cm3', 16.0) + + # Gyroid + x0 = openmc.XPlane(-0.5, boundary_type="periodic") + x1 = openmc.XPlane(+0.5, boundary_type="periodic") + y0 = openmc.YPlane(-0.5, boundary_type="periodic") + y1 = openmc.YPlane(+0.5, boundary_type="periodic") + z0 = openmc.ZPlane(-0.5, boundary_type="periodic") + z1 = openmc.ZPlane(+0.5, boundary_type="periodic") + x0.periodic_surface = x1 + y0.periodic_surface = y1 + z0.periodic_surface = z1 + box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 + impl = openmc.TPMS.from_pitch_isovalue("primitive", 1., 0.) + + fuel_cell = openmc.Cell(region=-impl & box, fill=material) + void_cell = openmc.Cell(region=+impl & box) + geometry = openmc.Geometry([fuel_cell, void_cell]) + + # Settings + settings = openmc.Settings() + settings.particles = 1000 + settings.batches = 20 + settings.inactive = 5 + settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} + + model = openmc.Model(settings=settings, geometry=geometry) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Point((0., 0., 0.)), + ) + return model + + +def test_implicit_sphere(implicit_sphere_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) + harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/sphere/inputs_true.dat b/tests/regression_tests/implicit/sphere/inputs_true.dat index b27c3fc8b5c..06d81baed1e 100644 --- a/tests/regression_tests/implicit/sphere/inputs_true.dat +++ b/tests/regression_tests/implicit/sphere/inputs_true.dat @@ -37,9 +37,12 @@ 0.0 0.0 0.0 - - true - + + fast + 1e-10 + 1e-10 + 5e-09 + diff --git a/tests/regression_tests/implicit/sphere/results_true.dat b/tests/regression_tests/implicit/sphere/results_true.dat index 18b45d12b24..8e8a9d2b65a 100644 --- a/tests/regression_tests/implicit/sphere/results_true.dat +++ b/tests/regression_tests/implicit/sphere/results_true.dat @@ -1,2 +1,2 @@ k-combined: -1.009527E+00 7.071045E-03 +1.009025E+00 5.384496E-03 diff --git a/tests/regression_tests/implicit/sphere/test.py b/tests/regression_tests/implicit/sphere/test.py index c46abf48a61..9203eeb46b0 100644 --- a/tests/regression_tests/implicit/sphere/test.py +++ b/tests/regression_tests/implicit/sphere/test.py @@ -29,11 +29,11 @@ def implicit_sphere_model(): settings.particles = 1000 settings.batches = 20 settings.inactive = 5 + settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} model = openmc.Model(settings=settings, geometry=geometry) model.settings.source = openmc.IndependentSource( space=openmc.stats.Point((0., 0., 0.)), - constraints={'fissionable': True}, ) return model diff --git a/tests/regression_tests/implicit/sphere_classic/__init__.py b/tests/regression_tests/implicit/sphere_classic/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/regression_tests/implicit/sphere_classic/inputs_true.dat b/tests/regression_tests/implicit/sphere_classic/inputs_true.dat new file mode 100644 index 00000000000..1189564f3bc --- /dev/null +++ b/tests/regression_tests/implicit/sphere_classic/inputs_true.dat @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + eigenvalue + 1000 + 20 + 5 + + + 0.0 0.0 0.0 + + + + fast + 1e-10 + 1e-10 + 5e-09 + + + diff --git a/tests/regression_tests/implicit/sphere_classic/results_true.dat b/tests/regression_tests/implicit/sphere_classic/results_true.dat new file mode 100644 index 00000000000..8e8a9d2b65a --- /dev/null +++ b/tests/regression_tests/implicit/sphere_classic/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +1.009025E+00 5.384496E-03 diff --git a/tests/regression_tests/implicit/sphere_classic/test.py b/tests/regression_tests/implicit/sphere_classic/test.py new file mode 100644 index 00000000000..1339dfff938 --- /dev/null +++ b/tests/regression_tests/implicit/sphere_classic/test.py @@ -0,0 +1,39 @@ +import openmc +import pytest + +from openmc.surface import ImplicitSurface +from openmc.implicit import X, Y, Z +from tests.testing_harness import PyAPITestHarness + + +@pytest.fixture() +def implicit_sphere_model(): + # Material + material = openmc.Material() + material.add_nuclide('U235', 1.0) + material.set_density('g/cm3', 16.0) + + R = 10.0 + outer = openmc.Sphere(r=R * 1.1, boundary_type='vacuum') + impl = openmc.Sphere(r=R) + + fuel_cell = openmc.Cell(region=-impl & -outer, fill=material) + void_cell = openmc.Cell(region=+impl & -outer) + geometry = openmc.Geometry([fuel_cell, void_cell]) + + # Settings + settings = openmc.Settings() + settings.particles = 1000 + settings.batches = 20 + settings.inactive = 5 + + model = openmc.Model(settings=settings, geometry=geometry) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Point((0., 0., 0.)), + ) + return model + + +def test_implicit_sphere(implicit_sphere_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) + harness.main() \ No newline at end of file diff --git a/tests/unit_tests/settings.xml b/tests/unit_tests/settings.xml new file mode 100644 index 00000000000..1f1b80fc60c --- /dev/null +++ b/tests/unit_tests/settings.xml @@ -0,0 +1,7 @@ + + + eigenvalue + + 0 + + diff --git a/tests/unit_tests/test_implicit.py b/tests/unit_tests/test_implicit.py index fc7cd37c907..55f7e7fb941 100644 --- a/tests/unit_tests/test_implicit.py +++ b/tests/unit_tests/test_implicit.py @@ -74,6 +74,13 @@ def test_log_negative(): def test_pow_float(): with pytest.raises(TypeError): X() ** 2.0 + with pytest.raises(TypeError): + Pow(X(), 0.5) + +def test_pow_neg(): + with pytest.raises(TypeError): + X() ** -2 + def test_pow_non_scalar_via_operator(): with pytest.raises(TypeError): @@ -175,13 +182,13 @@ def test_shared_node_first_to_cache_then_from_cache(): assert first.get("id") == second.get("id") == "0" def test_distinct_cached_nodes_get_distinct_ids(): - node1 = Cached(X()) - node2 = Cached(Y()) - _cached = [] - ex = node1.to_xml_element(_cached) - ey = node2.to_xml_element(_cached) - assert ex.get("id") == "0" - assert ey.get("id") == "1" + node1 = Cached(X()) + node2 = Cached(Y()) + _cached = [] + ex = node1.to_xml_element(_cached) + ey = node2.to_xml_element(_cached) + assert ex.get("id") == "0" + assert ey.get("id") == "1" def test_cached_in_expression(): cx = Cached(X()) diff --git a/tests/unit_tests/test_implicit_surface.py b/tests/unit_tests/test_implicit_surface.py index e607eca20bd..2b87a50ad88 100644 --- a/tests/unit_tests/test_implicit_surface.py +++ b/tests/unit_tests/test_implicit_surface.py @@ -7,6 +7,8 @@ import warnings import numpy as np +import h5py +import lxml.etree as ET import pytest import openmc.implicit as impl @@ -101,6 +103,14 @@ def test_repr_does_not_crash(): assert "Function" in s assert "Isovalue" in s +def test_normalize(): + surf = ImplicitSurface( + function=X(), isovalue=3.14, + x0=1., y0=2., z0=3.) + coeffs = surf.normalize() + assert coeffs[0]==1. + assert coeffs[1]==2. + assert coeffs[2]==3. # ============================================================================== # evaluate @@ -276,6 +286,12 @@ def test_pivot_translates_origin(): assert rotated.y0 == pytest.approx(5., abs=1e-10) assert rotated.z0 == pytest.approx(0., abs=1e-10) +def test_rotate_inplace_modifies_same_object(): + """inplace=True modifies and returns the same object.""" + surf = ImplicitSurface(function=X(), x0=1.) + result = surf.rotate([0., 0., 90.], inplace=True) + assert result.id == surf.id + assert result.y0 == pytest.approx(1.) # ============================================================================== # is_equal and clone @@ -409,6 +425,30 @@ def test_reused_cached_no_warning(): warnings.simplefilter("error", UserWarning) surf.to_xml_element() # must not raise +def test_implicit_surface_from_hdf5(): + func = X()**2 + Y()**2 + Z()**2 + orig = ImplicitSurface(function=func, isovalue=25.) + + # Build the XML string that C++ to_xml_string() produces + fnode = ET.Element("function") + fnode.append(func.to_xml_element([])) + xml_str = ET.tostring(fnode, encoding='unicode').encode() + + with h5py.File('test.h5', 'w', driver='core', backing_store=False) as f: + g = f.create_group('surface 1') + g.create_dataset('type', data=b'implicit') + g.create_dataset('boundary_type', data=b'transmission') + g.create_dataset('coefficients', + data=np.array([0.,0.,0.,1.,0.,0.,0.,1.,0.,0.,0.,1.])) + g.create_dataset('isovalue', data=25.) + g.create_dataset('function_xml', data=xml_str) + + surf = ImplicitSurface.from_hdf5(g) + + assert isinstance(surf, ImplicitSurface) + assert surf.isovalue == pytest.approx(25.) + for pt in [(0.,0.,0.), (3.,4.,0.), (5.,0.,0.), (6.,0.,0.)]: + assert surf.evaluate(pt) == pytest.approx(orig.evaluate(pt), abs=1e-10) # ============================================================================== # TPMS diff --git a/tests/unit_tests/test_settings_implicit.py b/tests/unit_tests/test_settings_implicit.py new file mode 100644 index 00000000000..c4ed22aab37 --- /dev/null +++ b/tests/unit_tests/test_settings_implicit.py @@ -0,0 +1,213 @@ +""" +Unit tests for implicit surface solver settings. + +Tests: + - Default values + - Valid assignments + - Invalid assignments raise errors + - XML round-trip preserves all values + - ImplicitSurface uses the solver specified in Settings +""" + +import pytest +from collections.abc import Mapping + +import openmc + + +# ============================================================================== +# Default value +# ============================================================================== + + +def test_implicit_default_is_none_or_dict(): + """Before assignment, implicit should be None or an empty dict.""" + s = openmc.Settings() + assert s.implicit is None or isinstance(s.implicit, Mapping) + + +# ============================================================================== +# Valid assignments +# ============================================================================== + + +def test_empty_dict(): + """Empty dict is valid — all values remain at C++ defaults.""" + s = openmc.Settings() + s.implicit = {} + assert s.implicit == {} + +@pytest.mark.parametrize("name", ["naive", "fast"]) +def test_valid_solver_names(name): + s = openmc.Settings() + s.implicit = {'name': name} + assert s.implicit['name'] == name + +def test_set_atol(): + s = openmc.Settings() + s.implicit = {'atol': 1e-10} + assert s.implicit['atol'] == pytest.approx(1e-10) + +def test_set_ftol(): + s = openmc.Settings() + s.implicit = {'ftol': 1e-6} + assert s.implicit['ftol'] == pytest.approx(1e-6) + +def test_set_maxiter(): + s = openmc.Settings() + s.implicit = {'maxiter': 5000} + assert s.implicit['maxiter'] == 5000 + +def test_set_margin(): + s = openmc.Settings() + s.implicit = {'margin': 2e-8} + assert s.implicit['margin'] == pytest.approx(2e-8) + +def test_set_all_fields(): + s = openmc.Settings() + s.implicit = { + 'name': 'fast', + 'atol': 1e-9, + 'ftol': 1e-6, + 'maxiter': 500, + 'margin': 3e-8, + } + assert s.implicit['name'] == 'fast' + assert s.implicit['atol'] == pytest.approx(1e-9) + assert s.implicit['ftol'] == pytest.approx(1e-6) + assert s.implicit['maxiter'] == 500 + assert s.implicit['margin'] == pytest.approx(3e-8) + + +# ============================================================================== +# Invalid assignments +# ============================================================================== + +def test_not_a_dict(): + """Setting implicit to a non-Mapping raises ValueError.""" + s = openmc.Settings() + with pytest.raises(ValueError): + s.implicit = 'fast' + +def test_not_a_dict_list(): + s = openmc.Settings() + with pytest.raises(ValueError): + s.implicit = ['fast', 1e-8] + +def test_unknown_key(): + """Unknown keys raise ValueError.""" + s = openmc.Settings() + with pytest.raises(ValueError): + s.implicit = {'unknown_key': 1} + +def test_atol_negative(): + s = openmc.Settings() + with pytest.raises((ValueError, Exception)): + s.implicit = {'atol': -1e-8} + +def test_atol_zero(): + s = openmc.Settings() + with pytest.raises((ValueError, Exception)): + s.implicit = {'atol': 0.0} + +def test_atol_wrong_type(): + s = openmc.Settings() + with pytest.raises((ValueError, TypeError, Exception)): + s.implicit = {'atol': 'small'} + +def test_maxiter_negative(): + s = openmc.Settings() + with pytest.raises((ValueError, Exception)): + s.implicit = {'maxiter': -1} + +def test_maxiter_zero(): + s = openmc.Settings() + with pytest.raises((ValueError, Exception)): + s.implicit = {'maxiter': 0} + +def test_maxiter_wrong_type(): + s = openmc.Settings() + with pytest.raises((ValueError, TypeError, Exception)): + s.implicit = {'maxiter': 1.5} + +def test_ftol_negative(): + s = openmc.Settings() + with pytest.raises((ValueError, Exception)): + s.implicit = {'ftol': -1.0} + +def test_margin_negative(): + s = openmc.Settings() + with pytest.raises((ValueError, Exception)): + s.implicit = {'margin': -1e-7} + +def test_name_wrong_type(): + s = openmc.Settings() + with pytest.raises((ValueError, TypeError, Exception)): + s.implicit = {'name': 42} + +def test_name_wrong_name(): + s = openmc.Settings() + with pytest.raises((ValueError, TypeError, Exception)): + s.implicit = {'name': 'screugneugneu'} + +# ============================================================================== +# XML round-trip +# ============================================================================== + +def _roundtrip(settings): + elem = settings.to_xml_element() + restored = openmc.Settings() + return restored.from_xml_element(elem) + +def test_name_preserved(): + s = openmc.Settings() + s.implicit = {'name': 'naive'} + r = _roundtrip(s) + assert r.implicit['name'] == 'naive' + +def test_atol_preserved(): + s = openmc.Settings() + s.implicit = {'atol': 1e-10} + r = _roundtrip(s) + assert r.implicit['atol'] == pytest.approx(1e-10) + +def test_maxiter_preserved(): + s = openmc.Settings() + s.implicit = {'maxiter': 2000} + r = _roundtrip(s) + assert r.implicit['maxiter'] == 2000 + +def test_ftol_preserved(): + s = openmc.Settings() + s.implicit = {'ftol': 1e-6} + r = _roundtrip(s) + assert r.implicit['ftol'] == pytest.approx(1e-6) + +def test_margin_preserved(): + s = openmc.Settings() + s.implicit = {'margin': 2e-7} + r = _roundtrip(s) + assert r.implicit['margin'] == pytest.approx(2e-7) + +def test_implicit_element_present_in_xml(): + """ element must appear in settings XML.""" + s = openmc.Settings() + s.implicit = {'name': 'fast', 'atol': 1e-9} + elem = s.to_xml_element() + assert elem.find('implicit') is not None + +def test_full_roundtrip(): + s = openmc.Settings() + s.implicit = { + 'name': 'naive', + 'atol': 1e-9, + 'ftol': 1e-6, + 'maxiter': 300, + 'margin': 5e-8, + } + r = _roundtrip(s) + assert r.implicit['name'] == 'naive' + assert r.implicit['atol'] == pytest.approx(1e-9) + assert r.implicit['ftol'] == pytest.approx(1e-6) + assert r.implicit['maxiter'] == 300 + assert r.implicit['margin'] == pytest.approx(5e-8) \ No newline at end of file From ff79bd67107b6efbb6396d1e15cb2ee04692b695 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Thu, 4 Jun 2026 20:20:42 -0600 Subject: [PATCH 07/22] Cache: lazy gradient to avoid solid ray plot error. Settings: check implicit solver name is valid. changed max_iter to maxiter to get the same var name everywhere. Cell: check if in cell before throwing fatal error to avoid error in solid ray plot. --- README.md | 16 ++++++++++++++++ include/openmc/implicit.h | 1 + include/openmc/settings.h | 2 +- openmc/settings.py | 7 ++----- src/cell.cpp | 37 +++++++++++++++++++++++++++++++++---- src/implicit.cpp | 9 +++++++-- src/settings.cpp | 6 +++--- src/surface.cpp | 2 +- 8 files changed, 64 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 866f24b5ae9..fe0039652f2 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,22 @@ This branch aims to develop a framework to create any surface defined by an impl Developer cross sections : https://anl.box.com/shared/static/teaup95cqv8s9nn56hfn7ku8mmelr95p.xz +## Quick install + +```sh +conda create -n openmc-IF compilers=1.9.0 cmake hdf5 python libpng +git clone --recurse-submodules git@github.inl.gov:paul-ferney/openmc-dev.git +cd openmc +git checkout ImplicitFunction +mkdir build +cd build +cmake -DOPENMC_ENABLE_STRICT_FP=on -DOPENMC_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/home/fernpa/miniforge/envs/openmc-IF/ .. +make -j 12 +make install +cd .. +python -m pip install .[test] +``` + # OpenMC Monte Carlo Particle Transport Code [![License](https://img.shields.io/badge/license-MIT-green)](https://docs.openmc.org/en/latest/license.html) diff --git a/include/openmc/implicit.h b/include/openmc/implicit.h index 5e1ddfd117f..3b8af7d4aa6 100644 --- a/include/openmc/implicit.h +++ b/include/openmc/implicit.h @@ -36,6 +36,7 @@ struct CacheEntry { Position pos {}; double val {0.0}; Gradient grad {0.0, 0.0, 0.0}; + bool grad_valid {false}; }; struct StepCache { diff --git a/include/openmc/settings.h b/include/openmc/settings.h index 237bfa5c0dd..644fc1f194f 100644 --- a/include/openmc/settings.h +++ b/include/openmc/settings.h @@ -205,7 +205,7 @@ extern double weight_cutoff; //!< Weight cutoff for Russian roulette extern double weight_survive; //!< Survival weight after Russian roulette // implicit solvers -extern int implicit_max_iter; +extern int implicit_maxiter; extern std::string implicit_solver; extern double implicit_atol; extern double implicit_ftol; diff --git a/openmc/settings.py b/openmc/settings.py index ba9d687307f..fcbd0e8a311 100644 --- a/openmc/settings.py +++ b/openmc/settings.py @@ -1513,6 +1513,7 @@ def implicit(self, implicit: dict): cv.check_greater_than('ftol', value, 0.0) elif key == 'name': cv.check_type('name', value, str) + cv.check_value('name', value, ['fast', 'naive']) elif key == 'margin': cv.check_type('margin', value, Real) cv.check_greater_than('margin', value, 0.0, True) @@ -2599,17 +2600,13 @@ def _free_gas_threshold_from_xml_element(self, root): self.free_gas_threshold = float(text) def _implicit_solvers_from_xml_element(self, root): - elem = root.find('implicit') - if elem is not None: - self.implicit = {} - elem = root.find('implicit') if elem is not None: self.implicit = {} for child in elem: if child.tag in ('atol', 'ftol', 'margin'): self.implicit[child.tag] = float(child.text) - elif child.tag == 'max_iter': + elif child.tag == 'maxiter': self.implicit[child.tag] = int(child.text) elif child.tag == 'name': self.implicit[child.tag] = str(child.text) diff --git a/src/cell.cpp b/src/cell.cpp index b12615423d1..7c4ebe01e73 100644 --- a/src/cell.cpp +++ b/src/cell.cpp @@ -982,10 +982,39 @@ std::pair Region::distance( // Finite region check if (!implicit_tokens.empty() && min_dist == INFTY) { - fatal_error( - "An implicit surface belongs to a region with no finite analytical " - "boundary. Implicit surfaces must be enclosed in a finite region " - "defined by standard surfaces (planes, spheres, cylinders, etc.)."); + // Ensure we are actually in the region: False errors sometimes comes with + // SolidRayTracing + bool isInCell = true; + for (int32_t token : expression_) { + if (token >= OP_UNION) + continue; + Surface* surf = model::surfaces[std::abs(token) - 1].get(); + if (surf->geom_type() != GeometryType::CSG) + continue; + + double f = surf->evaluate(r); + bool in_halfspace = (token < 0) ? (f < 0.) : (f > 0.); + if (!in_halfspace) { + isInCell = false; + break; + } + } + // If we are actually in the region: throw error, if not, return min dist. + if (isInCell) { + fatal_error( + "An implicit surface belongs to a region with no finite analytical " + "boundary. Implicit surfaces must be enclosed in a finite region " + "defined by standard surfaces (planes, spheres, cylinders, etc.)." + "r=(" + + std::to_string(r.x) + ", " + std::to_string(r.y) + ", " + + std::to_string(r.z) + + ")" + "u=(" + + std::to_string(u.x) + ", " + std::to_string(u.y) + ", " + + std::to_string(u.z) + ")"); + } else { + return {min_dist, i_surf}; + } } // Implicit surfaces treatment diff --git a/src/implicit.cpp b/src/implicit.cpp index 7261335ccaf..25554d87d83 100644 --- a/src/implicit.cpp +++ b/src/implicit.cpp @@ -944,7 +944,7 @@ void Cached::refresh(Position r) const if (entry.epoch != step_cache.epoch || r.x != entry.pos.x || r.y != entry.pos.y || r.z != entry.pos.z) { entry.val = child_->evaluate(r); - entry.grad = child_->gradient(r); + entry.grad_valid = false; // gradient invalidated, computed on demand entry.epoch = step_cache.epoch; entry.pos = r; } @@ -961,7 +961,12 @@ double Cached::evaluate(Position r) const Gradient Cached::gradient(Position r) const { refresh(r); - return step_cache.node_cache.at(this).grad; + auto& entry = step_cache.node_cache.at(this); + if (!entry.grad_valid) { + entry.grad = child_->gradient(r); // only computed when needed + entry.grad_valid = true; + } + return entry.grad; } double Cached::compute_lipschitz( Position r, Direction u, double t0, double t1) const diff --git a/src/settings.cpp b/src/settings.cpp index c4c22048d22..62ca55c7f8d 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -155,7 +155,7 @@ double weight_cutoff {0.25}; double weight_survive {1.0}; // implicit surface -int implicit_max_iter {1000000}; +int implicit_maxiter {1000000}; std::string implicit_solver {"fast"}; double implicit_atol {1.e-8}; double implicit_ftol {1.e-7}; @@ -1352,8 +1352,8 @@ void read_settings_xml(pugi::xml_node root) // Implicit surfaces settings if (check_for_node(root, "implicit")) { xml_node implicit_node = root.child("implicit"); - if (check_for_node(implicit_node, "max_iter")) { - implicit_max_iter = std::stoi(get_node_value(implicit_node, "max_iter")); + if (check_for_node(implicit_node, "maxiter")) { + implicit_maxiter = std::stoi(get_node_value(implicit_node, "maxiter")); } if (check_for_node(implicit_node, "name")) { implicit_solver = get_node_value(implicit_node, "name"); diff --git a/src/surface.cpp b/src/surface.cpp index 23b6ad43edd..ff6063bc59d 100644 --- a/src/surface.cpp +++ b/src/surface.cpp @@ -1184,7 +1184,7 @@ SurfaceImplicit::SurfaceImplicit(pugi::xml_node surf_node) : Surface(surf_node) fatal_error(fmt::format("Surface {} missing element.", id_)); function_ = Implicit::from_xml_element(func_node.first_child()); solver_ = ImplicitSolver::create(settings::implicit_solver, - settings::implicit_max_iter, settings::implicit_atol, + settings::implicit_maxiter, settings::implicit_atol, settings::implicit_ftol); } void SurfaceImplicit::to_hdf5_inner(hid_t group_id) const From 8fe28cc5819b2cbe0ebb809b57f5d56762049b3a Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Thu, 4 Jun 2026 20:21:31 -0600 Subject: [PATCH 08/22] Update sphere classic (no need implicit solver settings) --- .../implicit/sphere_classic/inputs_true.dat | 6 ------ ...{test_settings_implicit.py => test_implicit_settings.py} | 0 2 files changed, 6 deletions(-) rename tests/unit_tests/{test_settings_implicit.py => test_implicit_settings.py} (100%) diff --git a/tests/regression_tests/implicit/sphere_classic/inputs_true.dat b/tests/regression_tests/implicit/sphere_classic/inputs_true.dat index 1189564f3bc..7d0c2f534a4 100644 --- a/tests/regression_tests/implicit/sphere_classic/inputs_true.dat +++ b/tests/regression_tests/implicit/sphere_classic/inputs_true.dat @@ -22,11 +22,5 @@ 0.0 0.0 0.0 - - fast - 1e-10 - 1e-10 - 5e-09 - diff --git a/tests/unit_tests/test_settings_implicit.py b/tests/unit_tests/test_implicit_settings.py similarity index 100% rename from tests/unit_tests/test_settings_implicit.py rename to tests/unit_tests/test_implicit_settings.py From 9ca5936b2f2ac6cef4615f027dd8a87e90dae095 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Fri, 5 Jun 2026 12:00:38 -0600 Subject: [PATCH 09/22] Update new regression tests for more diversified functions --- .../regression_tests/implicit/diamond/test.py | 2 +- .../implicit/fuel_graded/__init__.py | 0 .../implicit/fuel_graded/inputs_true.dat | 78 ++++++++ .../implicit/fuel_graded/results_true.dat | 2 + .../implicit/fuel_graded/test.py | 53 +++++ .../implicit/primitive/test.py | 2 +- .../implicit/square_circle_fit/__init__.py | 0 .../square_circle_fit/inputs_true.dat | 184 ++++++++++++++++++ .../square_circle_fit/results_true.dat | 2 + .../implicit/square_circle_fit/test.py | 58 ++++++ .../implicit/squircle/__init__.py | 0 .../implicit/squircle/inputs_true.dat | 172 ++++++++++++++++ .../implicit/squircle/results_true.dat | 2 + .../implicit/squircle/test.py | 56 ++++++ tests/unit_tests/test_implicit.py | 21 +- 15 files changed, 621 insertions(+), 11 deletions(-) create mode 100644 tests/regression_tests/implicit/fuel_graded/__init__.py create mode 100644 tests/regression_tests/implicit/fuel_graded/inputs_true.dat create mode 100644 tests/regression_tests/implicit/fuel_graded/results_true.dat create mode 100644 tests/regression_tests/implicit/fuel_graded/test.py create mode 100644 tests/regression_tests/implicit/square_circle_fit/__init__.py create mode 100644 tests/regression_tests/implicit/square_circle_fit/inputs_true.dat create mode 100644 tests/regression_tests/implicit/square_circle_fit/results_true.dat create mode 100644 tests/regression_tests/implicit/square_circle_fit/test.py create mode 100644 tests/regression_tests/implicit/squircle/__init__.py create mode 100644 tests/regression_tests/implicit/squircle/inputs_true.dat create mode 100644 tests/regression_tests/implicit/squircle/results_true.dat create mode 100644 tests/regression_tests/implicit/squircle/test.py diff --git a/tests/regression_tests/implicit/diamond/test.py b/tests/regression_tests/implicit/diamond/test.py index 4ea5a255051..23579afdccc 100644 --- a/tests/regression_tests/implicit/diamond/test.py +++ b/tests/regression_tests/implicit/diamond/test.py @@ -14,7 +14,7 @@ def implicit_sphere_model(): material.add_nuclide('U238', 95.0) material.set_density('g/cm3', 16.0) - # Gyroid + # Diamond x0 = openmc.XPlane(-0.5, boundary_type="periodic") x1 = openmc.XPlane(+0.5, boundary_type="periodic") y0 = openmc.YPlane(-0.5, boundary_type="periodic") diff --git a/tests/regression_tests/implicit/fuel_graded/__init__.py b/tests/regression_tests/implicit/fuel_graded/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/regression_tests/implicit/fuel_graded/inputs_true.dat b/tests/regression_tests/implicit/fuel_graded/inputs_true.dat new file mode 100644 index 00000000000..9d3c7e4ef4a --- /dev/null +++ b/tests/regression_tests/implicit/fuel_graded/inputs_true.dat @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+
+
+
+ + + + + + + + +
+ + + + + + + + +
+
+
+
+ + eigenvalue + 1000 + 20 + 5 + + + 0.0 0.0 0.0 + + + + fast + 1e-10 + 1e-10 + 5e-09 + + +
diff --git a/tests/regression_tests/implicit/fuel_graded/results_true.dat b/tests/regression_tests/implicit/fuel_graded/results_true.dat new file mode 100644 index 00000000000..929119ee697 --- /dev/null +++ b/tests/regression_tests/implicit/fuel_graded/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +6.313117E-01 4.102208E-03 diff --git a/tests/regression_tests/implicit/fuel_graded/test.py b/tests/regression_tests/implicit/fuel_graded/test.py new file mode 100644 index 00000000000..3b1bcc92596 --- /dev/null +++ b/tests/regression_tests/implicit/fuel_graded/test.py @@ -0,0 +1,53 @@ +import openmc +import pytest +import numpy as np + +from openmc.surface import ImplicitSurface +from openmc.implicit import X, Y, Z, Cos, Sin, Cached +from tests.testing_harness import PyAPITestHarness + + +@pytest.fixture() +def implicit_sphere_model(): + # Material + material = openmc.Material() + material.add_nuclide('U235', 1.0) + material.set_density('g/cm3', 16.0) + + # Box + x0 = openmc.XPlane(-10., boundary_type="vacuum") + x1 = openmc.XPlane(+10., boundary_type="vacuum") + y0 = openmc.YPlane(-10., boundary_type="vacuum") + y1 = openmc.YPlane(+10., boundary_type="vacuum") + z0 = openmc.ZPlane(-10., boundary_type="vacuum") + z1 = openmc.ZPlane(+10., boundary_type="vacuum") + box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 + # Fuel grading + invPitch = Cached((Z() ** 2 + 2.5) / 5.) + x = 2 * np.pi * X() * invPitch + y = 2 * np.pi * Y() * invPitch + z = 2 * np.pi * Z() * invPitch + func = Cos(x) + Cos(y) + Cos(z) + impl = ImplicitSurface(function=func) + + fuel_cell = openmc.Cell(region=-impl & box, fill=material) + void_cell = openmc.Cell(region=+impl & box) + geometry = openmc.Geometry([fuel_cell, void_cell]) + + # Settings + settings = openmc.Settings() + settings.particles = 1000 + settings.batches = 20 + settings.inactive = 5 + settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} + + model = openmc.Model(settings=settings, geometry=geometry) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Point((0., 0., 0.)), + ) + return model + + +def test_implicit_sphere(implicit_sphere_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) + harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/primitive/test.py b/tests/regression_tests/implicit/primitive/test.py index d496e4a5ff9..19495da9c05 100644 --- a/tests/regression_tests/implicit/primitive/test.py +++ b/tests/regression_tests/implicit/primitive/test.py @@ -14,7 +14,7 @@ def implicit_sphere_model(): material.add_nuclide('U238', 95.0) material.set_density('g/cm3', 16.0) - # Gyroid + # Primitive x0 = openmc.XPlane(-0.5, boundary_type="periodic") x1 = openmc.XPlane(+0.5, boundary_type="periodic") y0 = openmc.YPlane(-0.5, boundary_type="periodic") diff --git a/tests/regression_tests/implicit/square_circle_fit/__init__.py b/tests/regression_tests/implicit/square_circle_fit/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/regression_tests/implicit/square_circle_fit/inputs_true.dat b/tests/regression_tests/implicit/square_circle_fit/inputs_true.dat new file mode 100644 index 00000000000..433f1c34f6b --- /dev/null +++ b/tests/regression_tests/implicit/square_circle_fit/inputs_true.dat @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+
+
+ +
+ + eigenvalue + 1000 + 20 + 5 + + + 0.0 0.0 0.0 + + + + fast + 1e-10 + 1e-10 + 5e-09 + + +
diff --git a/tests/regression_tests/implicit/square_circle_fit/results_true.dat b/tests/regression_tests/implicit/square_circle_fit/results_true.dat new file mode 100644 index 00000000000..34bdb0a537d --- /dev/null +++ b/tests/regression_tests/implicit/square_circle_fit/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +2.273580E+00 3.532542E-03 diff --git a/tests/regression_tests/implicit/square_circle_fit/test.py b/tests/regression_tests/implicit/square_circle_fit/test.py new file mode 100644 index 00000000000..51cb70001e8 --- /dev/null +++ b/tests/regression_tests/implicit/square_circle_fit/test.py @@ -0,0 +1,58 @@ +import openmc +import pytest +import numpy as np + +from openmc.surface import ImplicitSurface +from openmc.implicit import X, Y, Z, Cos, Abs, Max, Sqrt +from tests.testing_harness import PyAPITestHarness + + +@pytest.fixture() +def implicit_sphere_model(): + # Material + material = openmc.Material() + material.add_nuclide('U235', 1.0) + material.set_density('g/cm3', 16.0) + + # Gyroid + x0 = openmc.XPlane(-1., boundary_type="reflective") + x1 = openmc.XPlane(+1., boundary_type="reflective") + y0 = openmc.YPlane(-1., boundary_type="reflective") + y1 = openmc.YPlane(+1., boundary_type="reflective") + z0 = openmc.ZPlane(-1., boundary_type="reflective") + z1 = openmc.ZPlane(+1., boundary_type="reflective") + box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 + # Square Circle Fit + r = Sqrt(1. + X()**2 + Y()**2 + Z()**2) + c = 1. + Max(Max(Abs(X()), Abs(Y())), Abs(Z())) + x = X() * r / c + y = Y() * r / c + z = Z() * r / c + L = 0.5 + xp, yp, zp = 2*np.pi*x/L, 2*np.pi*y/L, 2*np.pi*z/L + func = Cos(xp) + Cos(yp) + Cos(zp) + impl = ImplicitSurface(function=func) + sphere = openmc.Sphere(r=2*L) + + fuel_cell = openmc.Cell(region=-impl & -sphere, fill=material) + void_cell = openmc.Cell(region=+impl & -sphere) + outer_cell = openmc.Cell(region=box & +sphere) + geometry = openmc.Geometry([fuel_cell, void_cell, outer_cell]) + + # Settings + settings = openmc.Settings() + settings.particles = 1000 + settings.batches = 20 + settings.inactive = 5 + settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} + + model = openmc.Model(settings=settings, geometry=geometry) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Point((0., 0., 0.)), + ) + return model + + +def test_implicit_sphere(implicit_sphere_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) + harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/squircle/__init__.py b/tests/regression_tests/implicit/squircle/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/regression_tests/implicit/squircle/inputs_true.dat b/tests/regression_tests/implicit/squircle/inputs_true.dat new file mode 100644 index 00000000000..2aca449f179 --- /dev/null +++ b/tests/regression_tests/implicit/squircle/inputs_true.dat @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+ + + + +
+
+
+ + + + +
+
+
+ + + + + + + + + +
+
+
+
+
+ +
+
+ +
+ + + + + + + + +
+ + + + +
+
+
+ + + + +
+
+
+ + + + + + + + + +
+
+
+
+
+ +
+
+
+ +
+ + + + + + + + +
+ + + + +
+
+
+ + + + +
+
+
+ + + + + + + + + +
+
+
+
+
+ +
+
+
+
+
+ +
+ + eigenvalue + 1000 + 20 + 5 + + + 0.0 0.0 0.0 + + + + fast + 1e-10 + 1e-10 + 5e-09 + + +
diff --git a/tests/regression_tests/implicit/squircle/results_true.dat b/tests/regression_tests/implicit/squircle/results_true.dat new file mode 100644 index 00000000000..6256abeb109 --- /dev/null +++ b/tests/regression_tests/implicit/squircle/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +2.279940E+00 2.272239E-03 diff --git a/tests/regression_tests/implicit/squircle/test.py b/tests/regression_tests/implicit/squircle/test.py new file mode 100644 index 00000000000..a80312accbd --- /dev/null +++ b/tests/regression_tests/implicit/squircle/test.py @@ -0,0 +1,56 @@ +import openmc +import pytest +import numpy as np + +from openmc.surface import ImplicitSurface +from openmc.implicit import X, Y, Z, Cos, Sqrt +from tests.testing_harness import PyAPITestHarness + + +@pytest.fixture() +def implicit_sphere_model(): + # Material + material = openmc.Material() + material.add_nuclide('U235', 1.0) + material.set_density('g/cm3', 16.0) + + # Box + x0 = openmc.XPlane(-1., boundary_type="reflective") + x1 = openmc.XPlane(+1., boundary_type="reflective") + y0 = openmc.YPlane(-1., boundary_type="reflective") + y1 = openmc.YPlane(+1., boundary_type="reflective") + z0 = openmc.ZPlane(-1., boundary_type="reflective") + z1 = openmc.ZPlane(+1., boundary_type="reflective") + box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 + # Squircle + x = X() * Sqrt(1 - Y()**2/2 - Z()**2/2 + Y()**2*Z()**2/3) + y = Y() * Sqrt(1 - X()**2/2 - Z()**2/2 + X()**2*Z()**2/3) + z = Z() * Sqrt(1 - X()**2/2 - Y()**2/2 + X()**2*Y()**2/3) + L = 0.5 + xp, yp, zp = 2*np.pi*x/L, 2*np.pi*y/L, 2*np.pi*z/L + func = Cos(xp) + Cos(yp) + Cos(zp) + impl = ImplicitSurface(function=func) + sphere = openmc.Sphere(r=2*L) + + fuel_cell = openmc.Cell(region=-impl & -sphere, fill=material) + void_cell = openmc.Cell(region=+impl & -sphere) + outer_cell = openmc.Cell(region=box & +sphere) + geometry = openmc.Geometry([fuel_cell, void_cell, outer_cell]) + + # Settings + settings = openmc.Settings() + settings.particles = 1000 + settings.batches = 20 + settings.inactive = 5 + settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} + + model = openmc.Model(settings=settings, geometry=geometry) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Point((0., 0., 0.)), + ) + return model + + +def test_implicit_sphere(implicit_sphere_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) + harness.main() \ No newline at end of file diff --git a/tests/unit_tests/test_implicit.py b/tests/unit_tests/test_implicit.py index 55f7e7fb941..5cb08bb2953 100644 --- a/tests/unit_tests/test_implicit.py +++ b/tests/unit_tests/test_implicit.py @@ -9,7 +9,7 @@ ImplicitFunction, X, Y, Z, Constant, Add, Sub, Mul, Div, Scale, Neg, Pow, - Sin, Cos, Sqrt, Exp, Log, Abs, + Sin, Cos, Sqrt, Exp, Log, Abs, Min, Max, Cached, ) @@ -81,7 +81,6 @@ def test_pow_neg(): with pytest.raises(TypeError): X() ** -2 - def test_pow_non_scalar_via_operator(): with pytest.raises(TypeError): X() ** Y() @@ -89,16 +88,16 @@ def test_pow_non_scalar_via_operator(): # ── operator overloading ─────────────────────────────────────────────────── -def test_add(): assert (X() + Y()).evaluate(PT) == 3.0 -def test_radd(): assert (1.0 + X()).evaluate(PT) == 2.0 -def test_sub(): assert (X() - Y()).evaluate(PT) == -1.0 -def test_rsub(): assert (3.0 - X()).evaluate(PT) == 2.0 -def test_mul_func(): assert (X() * Y()).evaluate(PT) == 2.0 +def test_add(): assert (X() + Y()).evaluate(PT) == 3.0 +def test_radd(): assert (1.0 + X()).evaluate(PT) == 2.0 +def test_sub(): assert (X() - Y()).evaluate(PT) == -1.0 +def test_rsub(): assert (3.0 - X()).evaluate(PT) == 2.0 +def test_mul_func(): assert (X() * Y()).evaluate(PT) == 2.0 def test_mul_scalar(): assert (X() * 3.0).evaluate(PT) == 3.0 def test_rmul_scalar(): assert (3.0 * X()).evaluate(PT) == 3.0 -def test_div(): assert (Y() / X()).evaluate(PT) == 2.0 +def test_div(): assert (Y() / X()).evaluate(PT) == 2.0 def test_rdiv(): assert (2.0 / Y()).evaluate(PT) == 1.0 -def test_neg(): assert (-X()).evaluate(PT) == -1.0 +def test_neg(): assert (-X()).evaluate(PT) == -1.0 def test_pow(): assert (Y() ** 2).evaluate(PT) == 4.0 def test_scalar_mul_produces_Scale(): assert isinstance(2 * X(), Scale) @@ -129,6 +128,8 @@ def test_nested(): (Exp(X()), "exp"), (Log(X()), "log"), (Abs(X()), "abs"), + (Min(X(), Y()), "min"), + (Max(X(), Y()), "max"), ]) def test_tag(node, expected_tag): assert node.to_xml_element([]).tag == expected_tag @@ -220,6 +221,8 @@ def test_cached_in_expression(): (Log(Constant(1.0)), 0.0), (Abs(Neg(Y())), 2.0), (X()**2 + Y()**2 + Z()**2, 14.0), + (Min(Y(), X()), 1.0), + (Max(Y(), X()), 2.0), ]) def test_roundtrip(func, expected): assert xml_roundtrip(func) == pytest.approx(expected) From 82cb7173409f0ad96d4855d03f96e17b606371f7 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Fri, 5 Jun 2026 12:01:34 -0600 Subject: [PATCH 10/22] Added Min Max nodes --- include/openmc/implicit.h | 38 ++++++++ openmc/implicit.py | 193 +++++++++++++++++++++++++++++++++++++- src/implicit.cpp | 83 ++++++++++++++++ 3 files changed, 310 insertions(+), 4 deletions(-) diff --git a/include/openmc/implicit.h b/include/openmc/implicit.h index 3b8af7d4aa6..bf1e5d91ef0 100644 --- a/include/openmc/implicit.h +++ b/include/openmc/implicit.h @@ -429,6 +429,44 @@ class Abs final : public Implicit { std::shared_ptr arg_; }; +class Min final : public Implicit { +public: + explicit Min(std::shared_ptr f, std::shared_ptr g) + : f_(std::move(f)), g_(std::move(g)) + {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr f_, g_; +}; + +class Max final : public Implicit { +public: + explicit Max(std::shared_ptr f, std::shared_ptr g) + : f_(std::move(f)), g_(std::move(g)) + {} + std::string expression() const override; + double evaluate(Position r) const override; + Gradient gradient(Position r) const override; + double compute_lipschitz( + Position r, Direction u, double t0, double t1) const override; + std::pair compute_f_min_max( + Position r, Direction u, double t0, double t1) const override; + pugi::xml_node to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const override; + +private: + std::shared_ptr f_, g_; +}; + // ============================================================================ // Cached node // diff --git a/openmc/implicit.py b/openmc/implicit.py index ada53c9da6a..4dc9a8f637b 100644 --- a/openmc/implicit.py +++ b/openmc/implicit.py @@ -16,6 +16,16 @@ def _to_function(f: int | float | ImplicitFunction) -> ImplicitFunction: # ------------------------------------------------------------------ class ImplicitFunction(ABC): + """Abstract base class for nodes in an implicit function expression tree. + + Subclasses represent either terminal values (coordinates, constants) or + operations that combine child nodes. The tree is evaluated by calling + :meth:`evaluate` and serialised to XML via :meth:`to_xml_element`. + + Operator overloading allows trees to be built with natural Python syntax:: + + f = Sin(2 * X()) * Cos(Z()) + Constant(1.) + """ @abstractmethod def __repr__(self) -> str: ... @@ -63,6 +73,8 @@ def from_xml_element(cls, element: ET.Element, _cached: dict | None = None) -> I "exp": lambda: Exp(*children), "log": lambda: Log(*children), "abs": lambda: Abs(*children), + "max": lambda: Max(*children), + "min": lambda: Min(*children), } if tag not in dispatch: @@ -71,7 +83,7 @@ def from_xml_element(cls, element: ET.Element, _cached: dict | None = None) -> I return dispatch[tag]() # ------------------------------------------------------------------ - # Operator overloading — enables natural Python expression syntax + # Operator overloading - enables natural Python expression syntax # e.g. Sin(X()) * Cos(Z()) + Constant(1) # ------------------------------------------------------------------ @@ -117,21 +129,31 @@ def __rmul__(self, other: float | ImplicitFunction) -> ImplicitFunction: # --------------------------------------------------------------------------- class X(ImplicitFunction): + """The x-coordinate: f(x, y, z) = x.""" def __repr__(self): return "X" def evaluate(self, point): return point[0] def to_xml_element(self, _cached=None): return ET.Element("x") class Y(ImplicitFunction): + """The y-coordinate: f(x, y, z) = y.""" def __repr__(self): return "Y" def evaluate(self, point): return point[1] def to_xml_element(self, _cached=None): return ET.Element("y") class Z(ImplicitFunction): + """The z-coordinate: f(x, y, z) = z.""" def __repr__(self): return "Z" def evaluate(self, point): return point[2] def to_xml_element(self, _cached=None): return ET.Element("z") class Constant(ImplicitFunction): + """A scalar constant: f(x, y, z) = value. + + Parameters + ---------- + value : float + The constant value. + """ def __repr__(self): return f"{self.value}" def __init__(self, value: float) -> None: self.value = float(value) @@ -146,7 +168,13 @@ def to_xml_element(self, _cached=None): # --------------------------------------------------------------------------- class Add(ImplicitFunction): + """Element-wise sum: f(x, y, z) = f(x,y,z) + g(x,y,z). + Parameters + ---------- + f, g : ImplicitFunction + Operands. + """ def __repr__(self): return f"{self.f} + {self.g}" def __init__(self, f:ImplicitFunction, g:ImplicitFunction): self.f = f @@ -160,6 +188,13 @@ def to_xml_element(self, _cached=None): return element class Neg(ImplicitFunction): + """Negation: f(x, y, z) = -f(x, y, z). + + Parameters + ---------- + f : ImplicitFunction + Operand. + """ def __repr__(self): return f"-{self.f}" def __init__(self, f:ImplicitFunction): self.f = f @@ -171,6 +206,13 @@ def to_xml_element(self, _cached=None): return element class Sub(ImplicitFunction): + """Element-wise difference: h(x, y, z) = f(x,y,z) - g(x,y,z). + + Parameters + ---------- + f, g : ImplicitFunction + Operands. + """ def __repr__(self): return f"{self.f} - {self.g}" def __init__(self, f:ImplicitFunction, g:ImplicitFunction): self.f = f @@ -184,6 +226,18 @@ def to_xml_element(self, _cached=None): return element class Scale(ImplicitFunction): + """Multiplication by a scalar constant: h = scalar * f. + + Prefer this over ``Mul(Constant(k), f)`` when one factor is a plain + number - the Lipschitz constant and interval bounds are tighter. + + Parameters + ---------- + f : ImplicitFunction + Function to scale. + scalar : float + Scalar multiplier. + """ def __repr__(self): return f"{self.scalar} * {self.f}" def __init__(self, f:ImplicitFunction, scalar: float): self.f = f @@ -197,6 +251,15 @@ def to_xml_element(self, _cached=None): return element class Mul(ImplicitFunction): + """Point-wise product of two functions: h = f * g. + + Use :class:`Scale` instead when one factor is a plain number. + + Parameters + ---------- + f, g : ImplicitFunction + Operands. + """ def __repr__(self): return f"{self.f} * {self.g}" def __init__(self, f:ImplicitFunction, g:ImplicitFunction): self.f = f @@ -210,6 +273,18 @@ def to_xml_element(self, _cached=None): return element class Div(ImplicitFunction): + """Point-wise quotient: h = f / g. + + Parameters + ---------- + f, g : ImplicitFunction + Numerator and denominator. + + Raises + ------ + ValueError + If ``g`` evaluates to zero at the query point. + """ def __repr__(self): return f"{self.f} / {self.g}" def __init__(self, f:ImplicitFunction, g:ImplicitFunction): self.f = f @@ -227,6 +302,22 @@ def to_xml_element(self, _cached=None): class Pow(ImplicitFunction): + """Integer power: h = f ** exp. + + Parameters + ---------- + f : ImplicitFunction + Base. + exp : int + Exponent - must be a strictly positive integer. + Use ``Div(Constant(1.), f)`` for exp = -1 and + ``Sqrt(f)`` for exp = 0.5. + + Raises + ------ + TypeError + If ``exp`` is not a strictly positive integer. + """ def __repr__(self): return f"{self.f} ** {self.exp}" def __init__(self, f:ImplicitFunction, exp: int): if not isinstance(exp, int) or exp <= 0: @@ -248,6 +339,13 @@ def to_xml_element(self, _cached=None): # --------------------------------------------------------------------------- class Sin(ImplicitFunction): + """Sine: h(x, y, z) = sin(arg(x, y, z)). + + Parameters + ---------- + arg : ImplicitFunction + Argument in radians. + """ def __repr__(self): return f"Sin({self.arg})" def __init__(self, arg:ImplicitFunction): self.arg = arg @@ -259,6 +357,13 @@ def to_xml_element(self, _cached=None): return element class Cos(ImplicitFunction): + """Cosine: h(x, y, z) = cos(arg(x, y, z)). + + Parameters + ---------- + arg : ImplicitFunction + Argument in radians. + """ def __repr__(self): return f"Cos({self.arg})" def __init__(self, arg:ImplicitFunction): self.arg = arg @@ -270,6 +375,18 @@ def to_xml_element(self, _cached=None): return element class Sqrt(ImplicitFunction): + """Square root: h(x, y, z) = sqrt(arg(x, y, z)). + + Parameters + ---------- + arg : ImplicitFunction + Argument - must be non-negative at every evaluation point. + + Raises + ------ + ValueError + If ``arg`` evaluates to a negative value. + """ def __repr__(self): return f"Sqrt({self.arg})" def __init__(self, arg:ImplicitFunction): self.arg = arg @@ -284,6 +401,13 @@ def to_xml_element(self, _cached=None): return element class Exp(ImplicitFunction): + """Natural exponential: h(x, y, z) = exp(arg(x, y, z)). + + Parameters + ---------- + arg : ImplicitFunction + Exponent. + """ def __repr__(self): return f"Exp({self.arg})" def __init__(self, arg:ImplicitFunction): self.arg = arg @@ -295,6 +419,18 @@ def to_xml_element(self, _cached=None): return element class Log(ImplicitFunction): + """Natural logarithm: h(x, y, z) = log(arg(x, y, z)). + + Parameters + ---------- + arg : ImplicitFunction + Argument - must be strictly positive at every evaluation point. + + Raises + ------ + ValueError + If ``arg`` evaluates to a non-positive value. + """ def __repr__(self): return f"Log({self.arg})" def __init__(self, arg:ImplicitFunction): self.arg = arg @@ -309,6 +445,13 @@ def to_xml_element(self, _cached=None): return element class Abs(ImplicitFunction): + """Absolute value: h(x, y, z) = |arg(x, y, z)|. + + Parameters + ---------- + arg : ImplicitFunction + Argument. + """ def __repr__(self): return f"|{self.arg}|" def __init__(self, arg:ImplicitFunction): self.arg = arg @@ -319,6 +462,48 @@ def to_xml_element(self, _cached=None): element.append(self.arg.to_xml_element(_cached)) return element +class Min(ImplicitFunction): + """Point-wise minimum: h(x,y,z) = min(f(x,y,z), g(x,y,z)). + + Parameters + ---------- + f, g : ImplicitFunction + Operands. + """ + def __repr__(self): return f"Min({self.f}, {self.g})" + def __init__(self, f: ImplicitFunction, g: ImplicitFunction): + self.f = f + self.g = g + def evaluate(self, point): + return np.min((self.f.evaluate(point), self.g.evaluate(point))) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("min") + element.append(self.f.to_xml_element(_cached)) + element.append(self.g.to_xml_element(_cached)) + return element + +class Max(ImplicitFunction): + """Point-wise maximum: h(x,y,z) = max(f(x,y,z), g(x,y,z)). + + Parameters + ---------- + f, g : ImplicitFunction + Operands. + """ + def __repr__(self): return f"Max({self.f}, {self.g})" + def __init__(self, f:ImplicitFunction, g:ImplicitFunction): + self.f = f + self.g = g + def evaluate(self, point): + return np.max((self.f.evaluate(point), self.g.evaluate(point))) + def to_xml_element(self, _cached=None): + if _cached is None: _cached = [] + element = ET.Element("max") + element.append(self.f.to_xml_element(_cached)) + element.append(self.g.to_xml_element(_cached)) + return element + # --------------------------------------------------------------------------- # Cache # --------------------------------------------------------------------------- @@ -329,14 +514,14 @@ class Cached(ImplicitFunction): The Python object identity (id()) is used to detect shared nodes during XML serialisation. This means the SAME Python object must be reused - wherever you want sharing to occur — do NOT construct a new Cached() + wherever you want sharing to occur - do NOT construct a new Cached() at each use site. - Correct — one object, two references: + Correct - one object, two references: cx = Cached(2 * np.pi * X()) f = Sin(cx) * Cos(cx) # cx serialised once as - Wrong — two objects, same expression: + Wrong - two objects, same expression: f = Sin(Cached(2 * np.pi * X())) * Cos(Cached(2 * np.pi * X())) """ def __repr__(self): return f"@[{self.f}]" diff --git a/src/implicit.cpp b/src/implicit.cpp index 25554d87d83..5b2a57fcb5c 100644 --- a/src/implicit.cpp +++ b/src/implicit.cpp @@ -98,6 +98,10 @@ std::shared_ptr Implicit::from_xml_element(pugi::xml_node node, return std::make_shared(children[0]); if (tag == "abs") return std::make_shared(children[0]); + if (tag == "max") + return std::make_shared(children[0], children[1]); + if (tag == "min") + return std::make_shared(children[0], children[1]); throw std::runtime_error( "Implicit::from_xml_element: unknown tag '" + tag + "'."); @@ -933,6 +937,85 @@ pugi::xml_node Abs::to_xml_element(pugi::xml_node parent, return node; } +//============================================================================== +// Min +//============================================================================== + +std::string Min::expression() const +{ + return "Min(" + f_->expression() + ", " + g_->expression() + ")"; +} +double Min::evaluate(Position r) const +{ + return std::min(f_->evaluate(r), g_->evaluate(r)); +} +Gradient Min::gradient(Position r) const +{ + return (f_->evaluate(r) <= g_->evaluate(r)) ? f_->gradient(r) + : g_->gradient(r); +} +double Min::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + // L(min(f,g)) = max(L_f, L_g) — same argument as Max by symmetry. + return std::max( + f_->compute_lipschitz(r, u, t0, t1), g_->compute_lipschitz(r, u, t0, t1)); +} +std::pair Min::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [f_min, f_max] = f_->compute_f_min_max(r, u, t0, t1); + auto [g_min, g_max] = g_->compute_f_min_max(r, u, t0, t1); + return {std::min(f_min, g_min), std::min(f_max, g_max)}; +} +pugi::xml_node Min::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("min"); + f_->to_xml_element(node, cache_map); + g_->to_xml_element(node, cache_map); + return node; +} + +//============================================================================== +// Max +//============================================================================== + +std::string Max::expression() const +{ + return "Max(" + f_->expression() + ", " + g_->expression() + ")"; +} +double Max::evaluate(Position r) const +{ + return std::max(f_->evaluate(r), g_->evaluate(r)); +} +Gradient Max::gradient(Position r) const +{ + return (f_->evaluate(r) >= g_->evaluate(r)) ? f_->gradient(r) + : g_->gradient(r); +} +double Max::compute_lipschitz( + Position r, Direction u, double t0, double t1) const +{ + return std::max( + f_->compute_lipschitz(r, u, t0, t1), g_->compute_lipschitz(r, u, t0, t1)); +} +std::pair Max::compute_f_min_max( + Position r, Direction u, double t0, double t1) const +{ + auto [f_min, f_max] = f_->compute_f_min_max(r, u, t0, t1); + auto [g_min, g_max] = g_->compute_f_min_max(r, u, t0, t1); + return {std::max(f_min, g_min), std::max(f_max, g_max)}; +} +pugi::xml_node Max::to_xml_element(pugi::xml_node parent, + std::unordered_map& cache_map) const +{ + auto node = parent.append_child("max"); + f_->to_xml_element(node, cache_map); + g_->to_xml_element(node, cache_map); + return node; +} + //============================================================================== // Cached //============================================================================== From 99793b9b4eb15a8a5e8405814c0517bac15840d0 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Fri, 5 Jun 2026 19:15:38 -0600 Subject: [PATCH 11/22] Wrote documentation. --- docs/source/pythonapi/base.rst | 11 + docs/source/pythonapi/implicit.rst | 32 +++ docs/source/pythonapi/index.rst | 1 + docs/source/usersguide/geometry.rst | 314 ++++++++++++++++++++++++++++ openmc/implicit.py | 2 +- openmc/surface.py | 161 ++++++++++++++ src/settings.cpp | 4 +- 7 files changed, 522 insertions(+), 3 deletions(-) create mode 100644 docs/source/pythonapi/implicit.rst diff --git a/docs/source/pythonapi/base.rst b/docs/source/pythonapi/base.rst index b3911c8b3ba..43732a2604a 100644 --- a/docs/source/pythonapi/base.rst +++ b/docs/source/pythonapi/base.rst @@ -83,6 +83,8 @@ Building geometry openmc.XTorus openmc.YTorus openmc.ZTorus + openmc.ImplicitSurface + openmc.TPMS openmc.Halfspace openmc.Intersection openmc.Union @@ -106,6 +108,15 @@ Many of the above classes are derived from several abstract classes: openmc.Region openmc.Lattice +Implicit surfaces are defined with a + +.. autosummary:: + :toctree: generated + :nosignatures: + :template: myclass.rst + + openmc.ImplicitFunction + .. _pythonapi_tallies: Constructing Tallies diff --git a/docs/source/pythonapi/implicit.rst b/docs/source/pythonapi/implicit.rst new file mode 100644 index 00000000000..5c1b7f12c9d --- /dev/null +++ b/docs/source/pythonapi/implicit.rst @@ -0,0 +1,32 @@ +.. _pythonapi_implicit: + +-------------------------------------------- +:mod:`openmc.implicit` -- Implicit Functions +-------------------------------------------- + +.. autosummary:: + :toctree: generated + :nosignatures: + :template: myclass.rst + + openmc.implicit.ImplicitFunction + openmc.implicit.X + openmc.implicit.Y + openmc.implicit.Z + openmc.implicit.Constant + openmc.implicit.Add + openmc.implicit.Sub + openmc.implicit.Neg + openmc.implicit.Scale + openmc.implicit.Mul + openmc.implicit.Div + openmc.implicit.Pow + openmc.implicit.Sin + openmc.implicit.Cos + openmc.implicit.Sqrt + openmc.implicit.Exp + openmc.implicit.Log + openmc.implicit.Abs + openmc.implicit.Min + openmc.implicit.Max + openmc.implicit.Cached diff --git a/docs/source/pythonapi/index.rst b/docs/source/pythonapi/index.rst index 6f832a935ec..470ddf9a724 100644 --- a/docs/source/pythonapi/index.rst +++ b/docs/source/pythonapi/index.rst @@ -45,6 +45,7 @@ or class. model examples deplete + implicit mgxs stats data diff --git a/docs/source/usersguide/geometry.rst b/docs/source/usersguide/geometry.rst index 8f83b9b084e..3e7341321a5 100644 --- a/docs/source/usersguide/geometry.rst +++ b/docs/source/usersguide/geometry.rst @@ -157,6 +157,320 @@ work:: (array([-0.8660254, -inf, -inf]), array([ 0.8660254, inf, inf])) +Implicit Surface with custom equations +-------------------------------------- + +In addition to the algebraic surfaces listed above, OpenMC supports +**implicit surfaces** defined by an arbitrary smooth function +:math:`f(x, y, z) = c`. This makes it possible to model geometries that +cannot be expressed as polynomials - most notably Triply Periodic Minimal +Surfaces (`TPMS `_). + +The surface is the level set: + +.. math:: + + f\!\left(\mathbf{R}(\mathbf{r} - \mathbf{r}_0)\right) = c + +where :math:`\mathbf{r}_0` is a translation vector, :math:`\mathbf{R}` is a +rotation matrix, and :math:`c` is the isovalue. The negative half-space +(:math:`f < c`) is the "inside" and the positive half-space (:math:`f > c`) +is the "outside". This formulation separates the surface geometry from the +coordinate transform, simplifying translation and rotation without modifying +the function itself. + +Defining a custom function +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Functions are built as expression trees using nodes from the +:mod:`openmc.implicit` module. Each node represents either a terminal value +or an operation, and they compose with natural Python syntax:: + + from openmc.implicit import X, Y, Z, Sin, Cos, Cached + import numpy as np + + # Sphere: f(x,y,z) = x² + y² + z² = R² + sphere_func = X()**2 + Y()**2 + Z()**2 + sphere = openmc.ImplicitSurface(function=sphere_func, isovalue=100.) + + # Gyroid TPMS with pitch L + L = 1.0 + cx = Cached(2 * np.pi * X() / L) + cy = Cached(2 * np.pi * Y() / L) + cz = Cached(2 * np.pi * Z() / L) + gyroid_func = Sin(cx)*Cos(cz) + Sin(cy)*Cos(cx) + Sin(cz)*Cos(cy) + gyroid = openmc.ImplicitSurface(function=gyroid_func, isovalue=0.) + +Alternatively, common TPMS families can be constructed directly from a pitch +and isovalue using the :class:`openmc.TPMS` convenience class:: + + gyroid = openmc.TPMS.from_pitch_isovalue('gyroid', pitch=1.0, isovalue=0.) + prim = openmc.TPMS.from_pitch_isovalue('primitive', pitch=1.0, isovalue=0.) + diamond = openmc.TPMS.from_pitch_isovalue('diamond', pitch=1.0, isovalue=0.) + +Available node types +~~~~~~~~~~~~~~~~~~~~ + +The following node types are available in :mod:`openmc.implicit`: + +.. table:: Terminal nodes + + +--------------------+---------------------------+ + | Node | Value | + +====================+===========================+ + | ``X()`` | :math:`x` | + +--------------------+---------------------------+ + | ``Y()`` | :math:`y` | + +--------------------+---------------------------+ + | ``Z()`` | :math:`z` | + +--------------------+---------------------------+ + | ``Constant(v)`` | :math:`v` | + +--------------------+---------------------------+ + +.. table:: Arithmetic nodes + + +--------------------+----------------------------------+ + | Node | Value | + +====================+==================================+ + | ``f + g`` | :math:`f + g` | + +--------------------+----------------------------------+ + | ``f - g`` | :math:`f - g` | + +--------------------+----------------------------------+ + | ``k * f`` | :math:`k \cdot f` (scalar) | + +--------------------+----------------------------------+ + | ``f * g`` | :math:`f \cdot g` | + +--------------------+----------------------------------+ + | ``f / g`` | :math:`f / g` :math:`g \ne 0` | + +--------------------+----------------------------------+ + | ``f ** n`` | :math:`f^n` (positive int only) | + +--------------------+----------------------------------+ + | ``Min(f, g)`` | :math:`\min(f, g)` | + +--------------------+----------------------------------+ + | ``Max(f, g)`` | :math:`\max(f, g)` | + +--------------------+----------------------------------+ + +.. table:: Transcendental nodes + + +--------------------+-------------------------------+ + | Node | Value | + +====================+===============================+ + | ``Sin(f)`` | :math:`\sin(f)` | + +--------------------+-------------------------------+ + | ``Cos(f)`` | :math:`\cos(f)` | + +--------------------+-------------------------------+ + | ``Sqrt(f)`` | :math:`\sqrt{f}`, :math:`f>0` | + +--------------------+-------------------------------+ + | ``Exp(f)`` | :math:`e^f` | + +--------------------+-------------------------------+ + | ``Log(f)`` | :math:`\ln f`, :math:`f>0` | + +--------------------+-------------------------------+ + | ``Abs(f)`` | :math:`|f|` | + +--------------------+-------------------------------+ + +.. note:: + + ``Pow`` only accepts strictly positive integer exponents. Use + ``Div(Constant(1.), f)`` for :math:`f^{-1}` and ``Sqrt(f)`` for + :math:`f^{0.5}`. + +Sub-expression caching +~~~~~~~~~~~~~~~~~~~~~~ + +When the same sub-expression appears multiple times in a function - as +``cx``, ``cy``, ``cz`` do in the gyroid example above - wrapping it in +:class:`~openmc.implicit.Cached` tells the C++ solver to compute it once and +reuse the result within each geometry step. This can significantly reduce +the number of function evaluations for complex TPMS expressions. + +The **same Python object** must be reused at every occurrence for sharing to +take effect. Constructing a new ``Cached(...)`` at each use site produces +independent caches with no benefit:: + + # Correct - one object, two references + cx = Cached(2 * np.pi * X()) + f = Sin(cx) + Cos(cx) + + # Wrong - two independent caches, no sharing + f = Sin(Cached(2 * np.pi * X())) + Cos(Cached(2 * np.pi * X())) + +Finite region requirement +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The implicit surface solver needs an upper bound on how far to search along +a ray. This bound is provided automatically by the nearest analytical surface +in the same cell region. **Every cell containing an implicit surface must +therefore also reference at least one finite analytical surface** (plane, +sphere, cylinder, etc.) to enclose it:: + + R = 10.0 + outer = openmc.Sphere(r=R * 1.1, boundary_type='vacuum') + impl = openmc.ImplicitSurface(function=gyroid_func, isovalue=0.) + + fuel_cell = openmc.Cell(region=-impl & -outer, fill=fuel) + void_cell = openmc.Cell(region=+impl & -outer) + +A fatal error is raised at runtime if an implicit surface is placed in a +region with no finite analytical boundary. + +Bounding region and Lipschitz computation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The size of the analytical bounding region matters beyond simply excluding +geometrically invalid points. Even when the implicit surface is well-defined +everywhere inside the cell, the solver may fail because it computes Lipschitz +bounds **over the entire ray interval** - from the particle's current position +to the nearest analytical boundary. If that interval passes through a region +where a node's domain condition is violated, the Lipschitz computation throws +a domain error before the solver begins marching. + +Consider the following example. The function involves ``Sqrt(x² + y² + z²)``, +which requires a strictly positive argument. The domain condition is satisfied +at every point in the shell :math:`1 \leq r \leq 5`, so the geometry appears +valid:: + + # Bounding shell + inner = openmc.Sphere(r=1.0) + outer = openmc.Sphere(r=5.0) + shell = +inner & -outer + + # Vacuum boundary + world = openmc.Sphere(r=20.0, boundary_type='vacuum') + + # Implicit surface + r = Sqrt(X()**2 + Y()**2 + Z()**2) + surf = ImplicitSurface(function=r, isovalue=5.) + + # Cells + fuel_cell = openmc.Cell(region=-surf & shell, fill=material) + void_cell = openmc.Cell(region=+surf & shell) + outer_cell = openmc.Cell(region= ~shell & -world) + geometry = openmc.Geometry([fuel_cell, void_cell, outer_cell]) + +This crashes with:: + + std::domain_error: Sqrt::compute_lipschitz: argument reaches zero or + negative on ray between r=(1.057, 0.571, -1.830) and r=(-4.235, -2.657, + 0.047) in expression X**2 + Y**2 + Z**2 + +The ray interval spans from the particle's current position to the nearest +bounding surface. Even though both endpoints lie within the shell, the ray is +a straight line whose closest approach to the origin can be much smaller than +either endpoint's radius - potentially passing through the region :math:`r = 0` +where ``Sqrt`` derivative is undefined. + +The fix is to subdivide the geometry into cells whose bounding regions +guarantee that the function is well-defined over every possible ray interval +within each cell. + +Ray-surface intersection solvers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Finding where a neutron ray crosses an implicit surface is not trivial - unlike +algebraic surfaces, there is no closed-form intersection formula. OpenMC uses +a **Lipschitz-marching** algorithm that guarantees no root is missed. + +The key insight is the Lipschitz condition: for a smooth function :math:`f`, +the rate of change along a ray is bounded by a constant :math:`L` (the +Lipschitz constant): + +.. math:: + + |f(t + \delta) - f(t)| \leq L \cdot \delta + +This means that if the function currently has value :math:`f(t)`, it cannot +reach zero before advancing at least :math:`|f(t)| / L` along the ray. The +solver exploits this to take large, guaranteed-safe steps, only slowing down +as it approaches a root. + +Two solvers are available: + +``naive`` + Computes :math:`L` once over the full ray interval and marches with steps + of size :math:`|f| / L`. When a sign change is detected, bisection + refines the root location. Simple and robust, but can be slow when + :math:`L` is large relative to the actual function variation (e.g. for + highly oscillatory TPMS over long rays). + +``fast`` + Subdivides the ray interval into a stack of sub-intervals and recomputes + tight Lipschitz bounds on each one. Sub-intervals where :math:`f` has + constant sign are discarded immediately; only intervals containing a root + are refined further. Significantly faster than ``naive`` for TPMS + geometries where the global :math:`L` is large but local bounds are tight. + +The solver is selected via :attr:`openmc.Settings.implicit`:: + + settings.implicit = {'name': 'fast'} # default + +Tuning solver parameters +~~~~~~~~~~~~~~~~~~~~~~~~ + +Several parameters control the accuracy and robustness of the solver and can +be set through :attr:`openmc.Settings.implicit`. + +``atol`` - geometric tolerance + The solver stops as soon as the estimated distance to the root is smaller + than ``atol`` [cm]. Decreasing ``atol`` gives a more accurate root + location at the cost of more function evaluations. The default value of + ``1e-9`` cm is appropriate for most + applications:: + + settings.implicit = {'atol': 1e-9} + +``ftol`` - function value tolerance + A root is also accepted when the function value :math:`|f - c|` falls + below ``ftol``. This can be useful when the surface passes through a + nearly-flat region where the gradient is small and the geometric distance + to the root is hard to estimate from :math:`L` alone. Defaults to + ``1e-9``:: + + settings.implicit = {'ftol': 1e-9} + +``maxiter`` - maximum solver iterations + Safety cap on the number of marching steps. If the solver reaches + ``maxiter`` without finding a root, it skips the surface and emits a + warning. This prevents an infinite loop when the function has very + small gradients over a long ray segment. The default of ``1 000 000`` + is rarely reached in practice but can be lowered for faster failure + detection during debugging:: + + settings.implicit = {'maxiter': 10000} + + If you see the warning + + .. code-block:: none + + WARNING: NaiveLipschitz reached max iterations. + + it might mean that the solver steps are very small relative to the length + of the bounding interval because of a large Lipschitz constant. Switching + to ``'fast'`` (which recomputes tighter per-interval bounds) may eliminate + the issue. + +``margin`` - coincident surface margin + After the solver finds a root, the intersection point is nudged forward + by ``margin`` [cm] to ensure the particle lands unambiguously on the + destination side of the surface. Without this nudge, floating-point + rounding can place the particle within the tolerance band of the surface, + causing OpenMC's ``sense()`` function to invoke the surface normal for a + tiebreak - which may fail for functions with restricted gradient domains. + The default of ``1e-7`` cm is sufficient for all tested geometries:: + + settings.implicit = {'margin': 1e-7} + + Increasing ``margin`` slightly can resolve rare geometry errors where a + particle appears to bounce back across a surface it just crossed. + +All parameters can be combined in a single assignment:: + + settings.implicit = { + 'name': 'fast', + 'atol': 1e-9, + 'ftol': 1e-9, + 'maxiter': 1000000, + 'margin': 1e-7, + } + Boundary Conditions ------------------- diff --git a/openmc/implicit.py b/openmc/implicit.py index 4dc9a8f637b..f909fd2ecc6 100644 --- a/openmc/implicit.py +++ b/openmc/implicit.py @@ -445,7 +445,7 @@ def to_xml_element(self, _cached=None): return element class Abs(ImplicitFunction): - """Absolute value: h(x, y, z) = |arg(x, y, z)|. + """Absolute value: h(x, y, z) = Abs(arg(x, y, z)). Parameters ---------- diff --git a/openmc/surface.py b/openmc/surface.py index 63ddce7d30b..56b0dc37a7b 100644 --- a/openmc/surface.py +++ b/openmc/surface.py @@ -2754,9 +2754,170 @@ def from_xml_element(elem): @staticmethod def from_hdf5(group): return Surface.from_hdf5(group) +"""An implicit surface defined by a user-specified function f(x, y, z) = c. + + The surface is the set of points where ``function(R @ (r - r0)) = isovalue``, + where ``r0 = (x0, y0, z0)`` is the translation vector and ``R`` is the + 3x3 rotation matrix encoded by coefficients ``a`` through ``i``. + + Unlike algebraic surfaces (planes, spheres, quadrics), the function is an + arbitrary smooth expression built from :mod:`openmc.implicit` nodes and + evaluated at runtime by the C++ NaiveLipschitz or FastLipschitz solver. + This makes ImplicitSurface suitable for TPMS geometries (gyroid, Schwartz-P, + diamond) and any other smooth level-set surface. + + Parameters + ---------- + function : ImplicitFunction + Expression tree representing f(x, y, z). Built from nodes in + :mod:`openmc.implicit` (``X()``, ``Sin``, ``Cos``, etc.). + isovalue : float, optional + Level-set value c such that the surface is f = c. Defaults to 0. + x0, y0, z0 : float, optional + Translation of the surface origin in world coordinates. Defaults to 0. + a, b, c, d, e, f, g, h, i : float, optional + Coefficients of the 3x3 rotation matrix R, stored row-major:: + + R = [[a, b, c], + [d, e, f], + [g, h, i]] + + Must form a valid rotation matrix (orthogonal, det = +1). + Defaults to the identity matrix. + boundary_type : str, optional + Must be ``'transmission'`` (the only supported boundary condition). + surface_id : int, optional + Unique identifier. Assigned automatically if not specified. + name : str, optional + Human-readable label. + + Raises + ------ + ValueError + If ``boundary_type`` is not ``'transmission'``. + ValueError + If the a-i coefficients do not form a valid rotation matrix. + TypeError + If ``function`` is not an :class:`~openmc.implicit.ImplicitFunction`. + + Notes + ----- + **Finite region requirement.** The C++ solver computes the distance to the + implicit surface using the distance to surrounding analytical surfaces as an + upper bound. Every cell containing an ImplicitSurface must therefore also + reference at least one finite analytical surface (plane, sphere, cylinder, + etc.) so that ``Region::distance`` can establish a bound for the solver. + A fatal error is raised at runtime if this condition is not met. + + **Caching.** Sub-expressions that appear more than once should be wrapped + in :class:`~openmc.implicit.Cached` to avoid redundant evaluations in the + C++ solver. See the :class:`~openmc.implicit.Cached` docstring for the + correct usage pattern. + + **Transform convention.** The surface evaluates the function in local + coordinates ``r_local = R @ (r - r0)``. Translating by ``v`` adds ``v`` + to ``r0``. Rotating by ``Rmat`` updates ``R ← R @ Rmat^T`` and + ``r0 ← Rmat @ r0``. + + Examples + -------- + A sphere of radius 5 defined implicitly: + + >>> from openmc.implicit import X, Y, Z + >>> func = X()**2 + Y()**2 + Z()**2 + >>> sphere = ImplicitSurface(function=func, isovalue=25.) + + A Schwartz-P TPMS with pitch 1 cm: + + >>> from openmc.surface import TPMS + >>> tpms = TPMS.from_pitch_isovalue('primitive', pitch=1.0, isovalue=0.) + """ class TPMS(ImplicitSurface): + """A Triply Periodic Minimal Surface (TPMS) implicit surface. + + Convenience subclass of :class:`ImplicitSurface` that constructs the + expression tree for common TPMS families from a pitch length and isovalue. + The resulting surface is periodic in all three Cartesian directions with + the given pitch. + + Do not instantiate directly — use the factory method + :meth:`from_pitch_isovalue`. + + Notes + ----- + TPMS geometries are widely used in nuclear fuel design for their high + surface-area-to-volume ratio, mechanical isotropy, and tuneable porosity. + The isovalue controls the volume fraction: at isovalue = 0 the surface + divides space into two equal-volume phases; positive values shift the + balance toward the positive half-space. + + The gyroid and diamond expressions use :class:`~openmc.implicit.Cached` + nodes for the scaled coordinates ``2π x / pitch``, ``2π y / pitch``, + ``2π z / pitch``, since each appears in two trigonometric sub-expressions. + + Examples + -------- + >>> gyroid = TPMS.from_pitch_isovalue('gyroid', pitch=1.0, isovalue=0.) + >>> prim = TPMS.from_pitch_isovalue('primitive', pitch=0.5, isovalue=0.3) + >>> diamond = TPMS.from_pitch_isovalue('diamond', pitch=1.0, isovalue=0., + ... surface_id=5) + """ + + @classmethod + def from_pitch_isovalue(cls, tpms: str, pitch: float, isovalue: float, + **kwargs) -> 'TPMS': + """Construct a TPMS surface from a pitch length and isovalue. + Parameters + ---------- + tpms : str + Name of the TPMS family. Case-insensitive. Supported values: + + +-----------------------+------------------------------------------+ + | Name | Equation | + +=======================+==========================================+ + | ``'primitive'``, | cos(x') + cos(y') + cos(z') = c | + | ``'schwarz_p'`` | | + +-----------------------+------------------------------------------+ + | ``'gyroid'``, | sin(x')cos(z') + sin(y')cos(x') | + | ``'schoen-g'`` | + sin(z')cos(y') = c | + +-----------------------+------------------------------------------+ + | ``'diamond'``, | sin(x')cos(y'-z') | + | ``'schwarz_d'`` | + sin(y'+z')cos(x') = c | + +-----------------------+------------------------------------------+ + + where ``x' = 2π x / pitch``, and similarly for y' and z'. + + pitch : float + Spatial period of the surface in [cm]. All three Cartesian + directions share the same pitch. + isovalue : float + Level-set value c. At ``isovalue = 0`` the surface is a true + minimal surface dividing space into two equal-volume phases. + Increasing the isovalue increases the volume fraction of the + negative half-space (the ``-surf`` region). + **kwargs + Additional keyword arguments passed to :class:`ImplicitSurface` + (e.g. ``surface_id``, ``name``, transform coefficients). + + Returns + ------- + TPMS + Constructed TPMS surface ready for use in cell definitions. + + Raises + ------ + NotImplementedError + If ``tpms`` is not one of the supported names. + + Examples + -------- + >>> surf = TPMS.from_pitch_isovalue('gyroid', pitch=1.0, isovalue=0.) + >>> surf.evaluate((0., 0., 0.)) # gyroid passes through the origin + 0.0 + """ + @classmethod def from_pitch_isovalue(cls, tpms:str, pitch:float, isovalue:float, **kwargs): # Shortcuts diff --git a/src/settings.cpp b/src/settings.cpp index 62ca55c7f8d..c03589c7a6e 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -157,8 +157,8 @@ double weight_survive {1.0}; // implicit surface int implicit_maxiter {1000000}; std::string implicit_solver {"fast"}; -double implicit_atol {1.e-8}; -double implicit_ftol {1.e-7}; +double implicit_atol {1.e-9}; +double implicit_ftol {1.e-9}; double implicit_margin {1.e-7}; } // namespace settings From 0ae47fc73b96ceb358e37c638d473f48375939d8 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Thu, 11 Jun 2026 14:53:44 -0600 Subject: [PATCH 12/22] Updated Cache to improve performance. --- include/openmc/implicit.h | 51 +++++++++++++++++------- src/implicit.cpp | 81 ++++++++++++++++++++++++++++++--------- 2 files changed, 101 insertions(+), 31 deletions(-) diff --git a/include/openmc/implicit.h b/include/openmc/implicit.h index bf1e5d91ef0..21c012837ef 100644 --- a/include/openmc/implicit.h +++ b/include/openmc/implicit.h @@ -33,17 +33,25 @@ using Gradient = Position; struct CacheEntry { uint64_t epoch {std::numeric_limits::max()}; - Position pos {}; - double val {0.0}; + Position r {0.0, 0.0, 0.0}; + double eval {0.0}; Gradient grad {0.0, 0.0, 0.0}; bool grad_valid {false}; + + // Interval cache — keyed on (u, t0, t1); r is covered by r + epoch. + Direction u {0.0, 0.0, 1.0}; + double t0 {0.0}, t1 {0.0}; + double L {1.0}; + bool L_valid {false}; + std::pair min_max {0.0, 0.0}; + bool min_max_valid {false}; }; struct StepCache { uint64_t epoch {0}; - // Per-thread cache keyed by node address. - // Grows to at most one entry per Cached node in the model — bounded and tiny. - std::unordered_map node_cache; + // Per-thread cache indexed by Cached::slot_. + // Grows to exactly one entry per Cached node in the model — bounded and tiny. + std::vector node_cache; }; extern thread_local StepCache step_cache; @@ -478,13 +486,13 @@ class Max final : public Implicit { // at many different positions within a single step, so the position check // is essential for correctness. // -// compute_lipschitz and compute_f_min_max are NOT cached — they operate on -// intervals, not single points, so the per-point cache does not apply. -// Both methods delegate directly to the child. +// compute_lipschitz and compute_f_min_max are cached per ray query, +// keyed on (epoch, r, u, t0, t1). Within one solver call the three +// branches of a shared subtree hit the cache on the 2nd and 3rd traversal. // // Thread safety: Cached has no mutable members. All cache data lives in -// thread_local step_cache.node_cache, keyed by node address. Each thread -// maintains a fully independent cache — no shared mutable state, no data race. +// thread_local step_cache.node_cache, indexed by slot. Each thread maintains +// a fully independent cache — no shared mutable state, no data race. // // The map grows to at most one entry per Cached node in the model and is // never cleared — entries are simply overwritten when the epoch or position @@ -499,7 +507,10 @@ class Max final : public Implicit { class Cached final : public Implicit { public: - explicit Cached(std::shared_ptr child) : child_(std::move(child)) {} + explicit Cached(std::shared_ptr child) + : child_(std::move(child)), slot_(next_slot_++) + {} + std::string expression() const override; double evaluate(Position r) const override; Gradient gradient(Position r) const override; @@ -511,10 +522,24 @@ class Cached final : public Implicit { std::unordered_map& cache_map) const override; private: - //! Recompute value and gradient if epoch or position changed. - void refresh(Position r) const; + //! Return this node's cache entry. + //! Grows the thread-local cache vector on first access. + CacheEntry& get_cache_entry() const; + + //! Recompute eval if the epoch or position changed. + CacheEntry& refresh(Position r) const; + + //! Like refresh, but additionally invalidates the interval cache + //! (L, min_max) if the ray parameters (u, t0, t1) changed. + CacheEntry& refresh_interval( + Position r, Direction u, double t0, double t1) const; + + //! Global counter assigning a unique slot to each Cached node. + //! Only incremented during model construction (single-threaded). + static std::atomic next_slot_; std::shared_ptr child_; + int slot_; }; } // namespace implicit diff --git a/src/implicit.cpp b/src/implicit.cpp index 5b2a57fcb5c..939e2210a8f 100644 --- a/src/implicit.cpp +++ b/src/implicit.cpp @@ -756,7 +756,7 @@ Gradient Sqrt::gradient(Position r) const double Sqrt::compute_lipschitz( Position r, Direction u, double t0, double t1) const { - // L(√f) = L(f) / (2 * √f_min) — derivative 1/(2√f) is largest at f_min + // L(√f) = L(f) / (2 * √f_min) - derivative 1/(2√f) is largest at f_min double lipschitz_arg = arg_->compute_lipschitz(r, u, t0, t1); auto [arg_min, arg_max] = arg_->compute_f_min_max(r, u, t0, t1); if (arg_min <= 0.0) @@ -957,7 +957,7 @@ Gradient Min::gradient(Position r) const double Min::compute_lipschitz( Position r, Direction u, double t0, double t1) const { - // L(min(f,g)) = max(L_f, L_g) — same argument as Max by symmetry. + // L(min(f,g)) = max(L_f, L_g) - same argument as Max by symmetry. return std::max( f_->compute_lipschitz(r, u, t0, t1), g_->compute_lipschitz(r, u, t0, t1)); } @@ -1020,58 +1020,103 @@ pugi::xml_node Max::to_xml_element(pugi::xml_node parent, // Cached //============================================================================== -void Cached::refresh(Position r) const +std::atomic Cached::next_slot_ {0}; + +CacheEntry& Cached::get_cache_entry() const { - auto& entry = step_cache.node_cache[this]; // creates default entry if absent + auto& cache = step_cache.node_cache; + if (static_cast(slot_) >= cache.size()) { + // First access on this thread - grow to cover all slots assigned so far. + cache.resize(next_slot_.load()); + } + return cache[slot_]; +} - if (entry.epoch != step_cache.epoch || r.x != entry.pos.x || - r.y != entry.pos.y || r.z != entry.pos.z) { - entry.val = child_->evaluate(r); - entry.grad_valid = false; // gradient invalidated, computed on demand +CacheEntry& Cached::refresh(Position r) const +{ + CacheEntry& entry = get_cache_entry(); + bool pos_match = entry.r.x == r.x && entry.r.y == r.y && entry.r.z == r.z; + bool epoch_match = entry.epoch == step_cache.epoch; + if (!epoch_match || !pos_match) { + entry.eval = child_->evaluate(r); + entry.grad_valid = false; + entry.L_valid = false; + entry.min_max_valid = false; entry.epoch = step_cache.epoch; - entry.pos = r; + entry.r = r; + } + return entry; +} + +CacheEntry& Cached::refresh_interval( + Position r, Direction u, double t0, double t1) const +{ + CacheEntry& entry = refresh(r); + bool dir_match = entry.u.x == u.x && entry.u.y == u.y && entry.u.z == u.z; + bool interval_match = entry.t0 == t0 && entry.t1 == t1; + if (!dir_match || !interval_match) { + entry.L_valid = false; + entry.min_max_valid = false; + entry.u = u; + entry.t0 = t0; + entry.t1 = t1; } + return entry; } + std::string Cached::expression() const { return "@[" + child_->expression() + "]"; } + double Cached::evaluate(Position r) const { - refresh(r); - return step_cache.node_cache.at(this).val; + return refresh(r).eval; } + Gradient Cached::gradient(Position r) const { - refresh(r); - auto& entry = step_cache.node_cache.at(this); + CacheEntry& entry = refresh(r); if (!entry.grad_valid) { - entry.grad = child_->gradient(r); // only computed when needed + entry.grad = child_->gradient(r); entry.grad_valid = true; } return entry.grad; } + double Cached::compute_lipschitz( Position r, Direction u, double t0, double t1) const { - return child_->compute_lipschitz(r, u, t0, t1); + CacheEntry& entry = refresh_interval(r, u, t0, t1); + if (!entry.L_valid) { + entry.L = child_->compute_lipschitz(r, u, t0, t1); + entry.L_valid = true; + } + return entry.L; } + std::pair Cached::compute_f_min_max( Position r, Direction u, double t0, double t1) const { - return child_->compute_f_min_max(r, u, t0, t1); + CacheEntry& entry = refresh_interval(r, u, t0, t1); + if (!entry.min_max_valid) { + entry.min_max = child_->compute_f_min_max(r, u, t0, t1); + entry.min_max_valid = true; + } + return entry.min_max; } + pugi::xml_node Cached::to_xml_element(pugi::xml_node parent, std::unordered_map& cache_map) const { auto it = cache_map.find(this); if (it != cache_map.end()) { - // Already emitted — write a back-reference + // Already emitted - write a back-reference auto node = parent.append_child("from_cache"); node.append_attribute("id") = it->second; return node; } - // First visit — register and emit the full subtree + // First visit - register and emit the full subtree int id = static_cast(cache_map.size()); cache_map[this] = id; auto node = parent.append_child("to_cache"); From 3217c11fbd13f5ba7461e1f7202f63e6c6b5a088 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Thu, 11 Jun 2026 15:13:50 -0600 Subject: [PATCH 13/22] Added relevant include to implicit.h for compiuler robustness. --- include/openmc/implicit.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/include/openmc/implicit.h b/include/openmc/implicit.h index 21c012837ef..2a63b46ab96 100644 --- a/include/openmc/implicit.h +++ b/include/openmc/implicit.h @@ -1,13 +1,16 @@ #ifndef OPENMC_IMPLICIT_H #define OPENMC_IMPLICIT_H +#include #include #include #include // For numeric_limits #include +#include #include #include #include +#include #include "pugixml.hpp" From 6f4c9cda0479cf0fa1b3e109c90ad0666c0e08a4 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Wed, 17 Jun 2026 16:18:33 -0600 Subject: [PATCH 14/22] fixed redudndant surface issue --- openmc/geometry.py | 15 +- tests/unit_tests/test_implicit_redundant.py | 173 ++++++++++++++++++++ 2 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 tests/unit_tests/test_implicit_redundant.py diff --git a/openmc/geometry.py b/openmc/geometry.py index 8496fb23ad7..9e9206b4454 100644 --- a/openmc/geometry.py +++ b/openmc/geometry.py @@ -701,9 +701,18 @@ def remove_redundant_surfaces(self) -> dict[int, openmc.Surface]: key = (surf._type, surf._boundary_type) + coeffs redundancies[key].append(surf) - redundant_surfaces = {replace.id: keep - for keep, *redundant in redundancies.values() - for replace in redundant} + redundant_surfaces = {} + for group in redundancies.values(): + kept = [group[0]] + for surf in group[1:]: + equivalent = next( + (keep for keep in kept if surf.is_equal(keep)), + None + ) + if equivalent is not None: + redundant_surfaces[surf.id] = equivalent + else: + kept.append(surf) if redundant_surfaces: # Iterate through all cells contained in the geometry diff --git a/tests/unit_tests/test_implicit_redundant.py b/tests/unit_tests/test_implicit_redundant.py new file mode 100644 index 00000000000..b2d6f5a24d1 --- /dev/null +++ b/tests/unit_tests/test_implicit_redundant.py @@ -0,0 +1,173 @@ +""" +Tests for Geometry.remove_redundant_surfaces with ImplicitSurface. + +The fix ensures that is_equal() is used within each coefficient-key bucket, +so that ImplicitSurface objects with identical transforms but different +isovalue or function are NOT incorrectly declared redundant. +""" + +import numpy as np +import pytest + +import openmc +from openmc.surface import ImplicitSurface +from openmc.implicit import X, Y, Z, Sin, Cos, Cached + + +# ============================================================================== +# Helpers +# ============================================================================== + +def make_geometry(*cells): + """Wrap cells in a minimal geometry.""" + universe = openmc.Universe(cells=list(cells)) + return openmc.Geometry(universe) + + +def make_cell(region, fill=None): + mat = openmc.Material() + mat.add_nuclide('U235', 1.0) + mat.set_density('g/cm3', 16.0) + return openmc.Cell(region=region, fill=fill or mat) + + +# ============================================================================== +# Standard surfaces — existing behaviour preserved +# ============================================================================== + +def test_identical_spheres_are_redundant(): + """Two spheres with the same radius are redundant.""" + s1 = openmc.Sphere(r=5.0, boundary_type='vacuum') + s2 = openmc.Sphere(r=5.0, boundary_type='vacuum') + c1 = make_cell(-s1) + c2 = make_cell(-s2) + geom = make_geometry(c1, c2) + redundant = geom.remove_redundant_surfaces() + assert len(redundant) == 1 + assert s2.id in redundant + assert redundant[s2.id] is s1 + +def test_different_spheres_are_not_redundant(): + """Two spheres with different radii are not redundant.""" + s1 = openmc.Sphere(r=5.0, boundary_type='vacuum') + s2 = openmc.Sphere(r=6.0, boundary_type='vacuum') + c1 = make_cell(-s1) + c2 = make_cell(-s2) + geom = make_geometry(c1, c2) + redundant = geom.remove_redundant_surfaces() + assert len(redundant) == 0 + +def test_no_redundant_surfaces(): + """Geometry with all unique surfaces returns empty dict.""" + s = openmc.Sphere(r=5.0, boundary_type='vacuum') + geom = make_geometry(make_cell(-s)) + assert geom.remove_redundant_surfaces() == {} + + +# ============================================================================== +# ImplicitSurface +# ============================================================================== + + +def test_same_function_same_isovalue_is_redundant(): + """Two implicit surfaces sharing the same function object and isovalue + are genuinely redundant.""" + func = X()**2 + Y()**2 + Z()**2 + outer = openmc.Sphere(r=11.0, boundary_type='vacuum') + s1 = ImplicitSurface(function=func, isovalue=25.) + s2 = ImplicitSurface(function=func, isovalue=25.) + c1 = make_cell(-s1 & -outer) + c2 = make_cell(-s2 & -outer) + geom = make_geometry(c1, c2) + redundant = geom.remove_redundant_surfaces() + assert len(redundant) == 1 + assert s2.id in redundant + assert redundant[s2.id] is s1 + +def test_same_function_different_isovalue_is_not_redundant(): + """Same function, different isovalue — different surfaces. + This was the bug: both surfaces had identical _coefficients keys + but different isovalues, yet were incorrectly declared redundant.""" + func = X()**2 + Y()**2 + Z()**2 + outer = openmc.Sphere(r=11.0, boundary_type='vacuum') + s1 = ImplicitSurface(function=func, isovalue=25.) # r = 5 + s2 = ImplicitSurface(function=func, isovalue=100.) # r = 10 + c1 = make_cell(-s1 & -outer) + c2 = make_cell(-s2 & -outer) + geom = make_geometry(c1, c2) + redundant = geom.remove_redundant_surfaces() + assert len(redundant) == 0 + +def test_different_function_objects_not_redundant(): + """Equivalent but distinct function objects are not redundant — + is_equal uses Python object identity for the function.""" + outer = openmc.Sphere(r=11.0, boundary_type='vacuum') + s1 = ImplicitSurface(function=X()**2 + Y()**2 + Z()**2, isovalue=25.) + s2 = ImplicitSurface(function=X()**2 + Y()**2 + Z()**2, isovalue=25.) + c1 = make_cell(-s1 & -outer) + c2 = make_cell(-s2 & -outer) + geom = make_geometry(c1, c2) + redundant = geom.remove_redundant_surfaces() + assert len(redundant) == 0 + +def test_different_transforms_not_redundant(): + """Same function and isovalue but different x0 — not redundant.""" + func = X()**2 + Y()**2 + Z()**2 + outer = openmc.Sphere(r=11.0, boundary_type='vacuum') + s1 = ImplicitSurface(function=func, isovalue=25., x0=0.) + s2 = ImplicitSurface(function=func, isovalue=25., x0=1.) + c1 = make_cell(-s1 & -outer) + c2 = make_cell(-s2 & -outer) + geom = make_geometry(c1, c2) + redundant = geom.remove_redundant_surfaces() + assert len(redundant) == 0 + +def test_tpms_same_function_object_is_redundant(): + """Two TPMS surfaces sharing the same underlying function object + and isovalue are correctly identified as redundant.""" + func = Sin(X()) * Cos(Z()) + Sin(Y()) * Cos(X()) + Sin(Z()) * Cos(Y()) + outer = openmc.Sphere(r=11.0, boundary_type='vacuum') + s1 = ImplicitSurface(function=func, isovalue=0.) + s2 = ImplicitSurface(function=func, isovalue=0.) + c1 = make_cell(-s1 & -outer) + c2 = make_cell(-s2 & -outer) + geom = make_geometry(c1, c2) + redundant = geom.remove_redundant_surfaces() + assert len(redundant) == 1 + assert redundant[s2.id] is s1 + + +# ============================================================================== +# Mixed geometry +# ============================================================================== + +def test_standard_and_implicit_both_handled(): + """In a geometry with both standard and implicit surfaces, + redundancy detection works correctly for both types independently.""" + # Standard redundant pair + sphere_a1 = openmc.Sphere(r=5.0) + sphere_a2 = openmc.Sphere(r=5.0) + + # Standard non-redundant pair + sphere_b = openmc.Sphere(r=8.0) + + # Implicit non-redundant pair (different isovalue) + func = X()**2 + Y()**2 + Z()**2 + outer = openmc.Sphere(r=11.0, boundary_type='vacuum') + impl1 = ImplicitSurface(function=func, isovalue=25.) + impl2 = ImplicitSurface(function=func, isovalue=64.) + + c1 = make_cell(-sphere_a1 & -outer) + c2 = make_cell(-sphere_a2 & -outer) + c3 = make_cell(-sphere_b & -outer) + c4 = make_cell(-impl1 & -outer) + c5 = make_cell(-impl2 & -outer) + geom = make_geometry(c1, c2, c3, c4, c5) + + redundant = geom.remove_redundant_surfaces() + + # Only sphere_a2 is redundant + assert len(redundant) == 1 + assert sphere_a2.id in redundant + assert impl1.id not in redundant + assert impl2.id not in redundant \ No newline at end of file From e2981273a749e7327760b32101ef66a37b1f9ee7 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Fri, 10 Jul 2026 16:21:20 -0600 Subject: [PATCH 15/22] ***Last commit v1, test update, surface compatibility with array of point, README.md notes for future improvement --- README.md | 4 + openmc/surface.py | 6 +- .../implicit/fuel_graded/inputs_true.dat | 16 +- .../implicit/fuel_graded/results_true.dat | 2 +- .../implicit/fuel_graded/test.py | 14 +- .../implicit/fuel_graded_no_cache/__init__.py | 0 .../fuel_graded_no_cache/inputs_true.dat | 92 +++++++ .../fuel_graded_no_cache/results_true.dat | 2 + .../implicit/fuel_graded_no_cache/test.py | 53 ++++ .../implicit/gyroid/inputs_true.dat | 18 +- .../implicit/gyroid/results_true.dat | 2 +- .../regression_tests/implicit/gyroid/test.py | 18 +- .../implicit/primitive/inputs_true.dat | 18 +- .../implicit/primitive/results_true.dat | 2 +- .../implicit/primitive/test.py | 18 +- .../square_circle_fit/inputs_true.dat | 171 +++++-------- .../square_circle_fit/results_true.dat | 2 +- .../implicit/square_circle_fit/test.py | 33 +-- .../implicit/squircle/inputs_true.dat | 237 +++++++++--------- .../implicit/squircle/results_true.dat | 2 +- .../implicit/squircle/test.py | 27 +- 21 files changed, 415 insertions(+), 322 deletions(-) create mode 100644 tests/regression_tests/implicit/fuel_graded_no_cache/__init__.py create mode 100644 tests/regression_tests/implicit/fuel_graded_no_cache/inputs_true.dat create mode 100644 tests/regression_tests/implicit/fuel_graded_no_cache/results_true.dat create mode 100644 tests/regression_tests/implicit/fuel_graded_no_cache/test.py diff --git a/README.md b/README.md index fe0039652f2..735b0e39f5f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ make install cd .. python -m pip install .[test] ``` +## Local Notes: +- TPMS: max event particles, to be checked +- TPMS: Particle could not be located after crossing a boundary of lattice +- SolidRayTracing, aliasing effect, to be checked # OpenMC Monte Carlo Particle Transport Code diff --git a/openmc/surface.py b/openmc/surface.py index 56b0dc37a7b..b80bfe28098 100644 --- a/openmc/surface.py +++ b/openmc/surface.py @@ -2681,7 +2681,11 @@ def _get_base_coeffs(self): def evaluate(self, point): Rmat = self.get_rotation_matrix() - newpoint = Rmat @ np.array([point[0] - self.x0, point[1] - self.y0, point[2] - self.z0]) + point = np.asarray(point) # handles tuple or array + translated = np.array([point[0] - self.x0, + point[1] - self.y0, + point[2] - self.z0]) # (3, ...) for any batch shape + newpoint = np.einsum('ij,j...->i...', Rmat, translated) # rotate, preserving batch dims return self.function.evaluate(newpoint) - self.isovalue def translate(self, vector, inplace=False): diff --git a/tests/regression_tests/implicit/fuel_graded/inputs_true.dat b/tests/regression_tests/implicit/fuel_graded/inputs_true.dat index 9d3c7e4ef4a..942947b615f 100644 --- a/tests/regression_tests/implicit/fuel_graded/inputs_true.dat +++ b/tests/regression_tests/implicit/fuel_graded/inputs_true.dat @@ -9,12 +9,12 @@ - - - - - - + + + + + + @@ -30,9 +30,9 @@ - + - + diff --git a/tests/regression_tests/implicit/fuel_graded/results_true.dat b/tests/regression_tests/implicit/fuel_graded/results_true.dat index 929119ee697..bd4ea33ea9b 100644 --- a/tests/regression_tests/implicit/fuel_graded/results_true.dat +++ b/tests/regression_tests/implicit/fuel_graded/results_true.dat @@ -1,2 +1,2 @@ k-combined: -6.313117E-01 4.102208E-03 +9.381729E-01 6.451632E-03 diff --git a/tests/regression_tests/implicit/fuel_graded/test.py b/tests/regression_tests/implicit/fuel_graded/test.py index 3b1bcc92596..ad8a10a9dca 100644 --- a/tests/regression_tests/implicit/fuel_graded/test.py +++ b/tests/regression_tests/implicit/fuel_graded/test.py @@ -15,15 +15,15 @@ def implicit_sphere_model(): material.set_density('g/cm3', 16.0) # Box - x0 = openmc.XPlane(-10., boundary_type="vacuum") - x1 = openmc.XPlane(+10., boundary_type="vacuum") - y0 = openmc.YPlane(-10., boundary_type="vacuum") - y1 = openmc.YPlane(+10., boundary_type="vacuum") - z0 = openmc.ZPlane(-10., boundary_type="vacuum") - z1 = openmc.ZPlane(+10., boundary_type="vacuum") + x0 = openmc.XPlane(-16., boundary_type="vacuum") + x1 = openmc.XPlane(+16., boundary_type="vacuum") + y0 = openmc.YPlane(-16., boundary_type="vacuum") + y1 = openmc.YPlane(+16., boundary_type="vacuum") + z0 = openmc.ZPlane(-16., boundary_type="vacuum") + z1 = openmc.ZPlane(+16., boundary_type="vacuum") box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 # Fuel grading - invPitch = Cached((Z() ** 2 + 2.5) / 5.) + invPitch = Cached((Z() ** 2 + 8.) / 64.) x = 2 * np.pi * X() * invPitch y = 2 * np.pi * Y() * invPitch z = 2 * np.pi * Z() * invPitch diff --git a/tests/regression_tests/implicit/fuel_graded_no_cache/__init__.py b/tests/regression_tests/implicit/fuel_graded_no_cache/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/regression_tests/implicit/fuel_graded_no_cache/inputs_true.dat b/tests/regression_tests/implicit/fuel_graded_no_cache/inputs_true.dat new file mode 100644 index 00000000000..864f50d186d --- /dev/null +++ b/tests/regression_tests/implicit/fuel_graded_no_cache/inputs_true.dat @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+
+
+ + + + + +
+ + + + + + + +
+
+
+
+ + + + + +
+ + + + + + + +
+
+
+
+
+
+
+ + eigenvalue + 1000 + 20 + 5 + + + 0.0 0.0 0.0 + + + + fast + 1e-10 + 1e-10 + 5e-09 + + +
diff --git a/tests/regression_tests/implicit/fuel_graded_no_cache/results_true.dat b/tests/regression_tests/implicit/fuel_graded_no_cache/results_true.dat new file mode 100644 index 00000000000..bd4ea33ea9b --- /dev/null +++ b/tests/regression_tests/implicit/fuel_graded_no_cache/results_true.dat @@ -0,0 +1,2 @@ +k-combined: +9.381729E-01 6.451632E-03 diff --git a/tests/regression_tests/implicit/fuel_graded_no_cache/test.py b/tests/regression_tests/implicit/fuel_graded_no_cache/test.py new file mode 100644 index 00000000000..b098676143f --- /dev/null +++ b/tests/regression_tests/implicit/fuel_graded_no_cache/test.py @@ -0,0 +1,53 @@ +import openmc +import pytest +import numpy as np + +from openmc.surface import ImplicitSurface +from openmc.implicit import X, Y, Z, Cos, Sin, Cached +from tests.testing_harness import PyAPITestHarness + + +@pytest.fixture() +def implicit_sphere_model(): + # Material + material = openmc.Material() + material.add_nuclide('U235', 1.0) + material.set_density('g/cm3', 16.0) + + # Box + x0 = openmc.XPlane(-16., boundary_type="vacuum") + x1 = openmc.XPlane(+16., boundary_type="vacuum") + y0 = openmc.YPlane(-16., boundary_type="vacuum") + y1 = openmc.YPlane(+16., boundary_type="vacuum") + z0 = openmc.ZPlane(-16., boundary_type="vacuum") + z1 = openmc.ZPlane(+16., boundary_type="vacuum") + box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 + # Fuel grading + invPitch = (Z() ** 2 + 8.) / 64. + x = 2 * np.pi * X() * invPitch + y = 2 * np.pi * Y() * invPitch + z = 2 * np.pi * Z() * invPitch + func = Cos(x) + Cos(y) + Cos(z) + impl = ImplicitSurface(function=func) + + fuel_cell = openmc.Cell(region=-impl & box, fill=material) + void_cell = openmc.Cell(region=+impl & box) + geometry = openmc.Geometry([fuel_cell, void_cell]) + + # Settings + settings = openmc.Settings() + settings.particles = 1000 + settings.batches = 20 + settings.inactive = 5 + settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} + + model = openmc.Model(settings=settings, geometry=geometry) + model.settings.source = openmc.IndependentSource( + space=openmc.stats.Point((0., 0., 0.)), + ) + return model + + +def test_implicit_sphere(implicit_sphere_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) + harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/gyroid/inputs_true.dat b/tests/regression_tests/implicit/gyroid/inputs_true.dat index 5b0a5936efd..660019af411 100644 --- a/tests/regression_tests/implicit/gyroid/inputs_true.dat +++ b/tests/regression_tests/implicit/gyroid/inputs_true.dat @@ -10,12 +10,12 @@ - - - - - - + + + + + + @@ -27,7 +27,7 @@ - +
@@ -37,7 +37,7 @@ - +
@@ -49,7 +49,7 @@ - + diff --git a/tests/regression_tests/implicit/gyroid/results_true.dat b/tests/regression_tests/implicit/gyroid/results_true.dat index 08676c4e9d2..922fd3be108 100644 --- a/tests/regression_tests/implicit/gyroid/results_true.dat +++ b/tests/regression_tests/implicit/gyroid/results_true.dat @@ -1,2 +1,2 @@ k-combined: -9.455384E-01 6.846333E-03 +2.365224E-01 2.042484E-03 diff --git a/tests/regression_tests/implicit/gyroid/test.py b/tests/regression_tests/implicit/gyroid/test.py index a6b1e40864d..da79c60897f 100644 --- a/tests/regression_tests/implicit/gyroid/test.py +++ b/tests/regression_tests/implicit/gyroid/test.py @@ -15,17 +15,15 @@ def implicit_sphere_model(): material.set_density('g/cm3', 16.0) # Gyroid - x0 = openmc.XPlane(-0.5, boundary_type="periodic") - x1 = openmc.XPlane(+0.5, boundary_type="periodic") - y0 = openmc.YPlane(-0.5, boundary_type="periodic") - y1 = openmc.YPlane(+0.5, boundary_type="periodic") - z0 = openmc.ZPlane(-0.5, boundary_type="periodic") - z1 = openmc.ZPlane(+0.5, boundary_type="periodic") - x0.periodic_surface = x1 - y0.periodic_surface = y1 - z0.periodic_surface = z1 + x0 = openmc.XPlane(-16.0, boundary_type="vacuum") + x1 = openmc.XPlane(+16.0, boundary_type="vacuum") + y0 = openmc.YPlane(-16.0, boundary_type="vacuum") + y1 = openmc.YPlane(+16.0, boundary_type="vacuum") + z0 = openmc.ZPlane(-16.0, boundary_type="vacuum") + z1 = openmc.ZPlane(+16.0, boundary_type="vacuum") box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 - impl = openmc.TPMS.from_pitch_isovalue("gyroid", 1., 0.) + + impl = openmc.TPMS.from_pitch_isovalue("gyroid", 4., 0.) fuel_cell = openmc.Cell(region=-impl & box, fill=material) void_cell = openmc.Cell(region=+impl & box) diff --git a/tests/regression_tests/implicit/primitive/inputs_true.dat b/tests/regression_tests/implicit/primitive/inputs_true.dat index f504bb6935b..20aace3ff40 100644 --- a/tests/regression_tests/implicit/primitive/inputs_true.dat +++ b/tests/regression_tests/implicit/primitive/inputs_true.dat @@ -10,12 +10,12 @@ - - - - - - + + + + + + @@ -25,7 +25,7 @@ - + @@ -33,7 +33,7 @@ - + @@ -42,7 +42,7 @@ - + diff --git a/tests/regression_tests/implicit/primitive/results_true.dat b/tests/regression_tests/implicit/primitive/results_true.dat index e0958c913b7..2a9592dc1fc 100644 --- a/tests/regression_tests/implicit/primitive/results_true.dat +++ b/tests/regression_tests/implicit/primitive/results_true.dat @@ -1,2 +1,2 @@ k-combined: -9.489407E-01 5.099686E-03 +2.369642E-01 2.281561E-03 diff --git a/tests/regression_tests/implicit/primitive/test.py b/tests/regression_tests/implicit/primitive/test.py index 19495da9c05..0862b9b7339 100644 --- a/tests/regression_tests/implicit/primitive/test.py +++ b/tests/regression_tests/implicit/primitive/test.py @@ -15,17 +15,15 @@ def implicit_sphere_model(): material.set_density('g/cm3', 16.0) # Primitive - x0 = openmc.XPlane(-0.5, boundary_type="periodic") - x1 = openmc.XPlane(+0.5, boundary_type="periodic") - y0 = openmc.YPlane(-0.5, boundary_type="periodic") - y1 = openmc.YPlane(+0.5, boundary_type="periodic") - z0 = openmc.ZPlane(-0.5, boundary_type="periodic") - z1 = openmc.ZPlane(+0.5, boundary_type="periodic") - x0.periodic_surface = x1 - y0.periodic_surface = y1 - z0.periodic_surface = z1 + x0 = openmc.XPlane(-16.0, boundary_type="vacuum") + x1 = openmc.XPlane(+16.0, boundary_type="vacuum") + y0 = openmc.YPlane(-16.0, boundary_type="vacuum") + y1 = openmc.YPlane(+16.0, boundary_type="vacuum") + z0 = openmc.ZPlane(-16.0, boundary_type="vacuum") + z1 = openmc.ZPlane(+16.0, boundary_type="vacuum") box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 - impl = openmc.TPMS.from_pitch_isovalue("primitive", 1., 0.) + + impl = openmc.TPMS.from_pitch_isovalue("primitive", 4., 0.) fuel_cell = openmc.Cell(region=-impl & box, fill=material) void_cell = openmc.Cell(region=+impl & box) diff --git a/tests/regression_tests/implicit/square_circle_fit/inputs_true.dat b/tests/regression_tests/implicit/square_circle_fit/inputs_true.dat index 433f1c34f6b..8264b28a2f1 100644 --- a/tests/regression_tests/implicit/square_circle_fit/inputs_true.dat +++ b/tests/regression_tests/implicit/square_circle_fit/inputs_true.dat @@ -10,159 +10,102 @@ - - - - - - + + + + + +
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
-
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
-
-
- -
- -
- - + + + + + + + + + - + - + + + + + - + + + + + - + + + - + - + - + -
-
- + +
+
+ +
+ + + + + + + +
+
+
+ +
+ + + + + + +
- +
eigenvalue diff --git a/tests/regression_tests/implicit/square_circle_fit/results_true.dat b/tests/regression_tests/implicit/square_circle_fit/results_true.dat index 34bdb0a537d..400a87fdce1 100644 --- a/tests/regression_tests/implicit/square_circle_fit/results_true.dat +++ b/tests/regression_tests/implicit/square_circle_fit/results_true.dat @@ -1,2 +1,2 @@ k-combined: -2.273580E+00 3.532542E-03 +8.188301E-01 5.825067E-03 diff --git a/tests/regression_tests/implicit/square_circle_fit/test.py b/tests/regression_tests/implicit/square_circle_fit/test.py index 51cb70001e8..c4502374e0f 100644 --- a/tests/regression_tests/implicit/square_circle_fit/test.py +++ b/tests/regression_tests/implicit/square_circle_fit/test.py @@ -3,7 +3,7 @@ import numpy as np from openmc.surface import ImplicitSurface -from openmc.implicit import X, Y, Z, Cos, Abs, Max, Sqrt +from openmc.implicit import X, Y, Z, Cos, Abs, Max, Sqrt, Cached from tests.testing_harness import PyAPITestHarness @@ -14,23 +14,24 @@ def implicit_sphere_model(): material.add_nuclide('U235', 1.0) material.set_density('g/cm3', 16.0) - # Gyroid - x0 = openmc.XPlane(-1., boundary_type="reflective") - x1 = openmc.XPlane(+1., boundary_type="reflective") - y0 = openmc.YPlane(-1., boundary_type="reflective") - y1 = openmc.YPlane(+1., boundary_type="reflective") - z0 = openmc.ZPlane(-1., boundary_type="reflective") - z1 = openmc.ZPlane(+1., boundary_type="reflective") + # Square Circle Fit + x0 = openmc.XPlane(-16., boundary_type="vacuum") + x1 = openmc.XPlane(+16., boundary_type="vacuum") + y0 = openmc.YPlane(-16., boundary_type="vacuum") + y1 = openmc.YPlane(+16., boundary_type="vacuum") + z0 = openmc.ZPlane(-16., boundary_type="vacuum") + z1 = openmc.ZPlane(+16., boundary_type="vacuum") box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 # Square Circle Fit - r = Sqrt(1. + X()**2 + Y()**2 + Z()**2) - c = 1. + Max(Max(Abs(X()), Abs(Y())), Abs(Z())) - x = X() * r / c - y = Y() * r / c - z = Z() * r / c - L = 0.5 - xp, yp, zp = 2*np.pi*x/L, 2*np.pi*y/L, 2*np.pi*z/L - func = Cos(xp) + Cos(yp) + Cos(zp) + L = 8.0 + scale = 1.0/L + xp, yp, zp = Cached(scale * X()), Cached(scale * Y()), Cached(scale * Z()) + r = Cached(Sqrt(1. + xp**2 + yp**2 + zp**2)) + c = Cached(1. + Max(Max(Abs(xp), Abs(yp)), Abs(zp))) + x = 2 * np.pi * xp * r / c + y = 2 * np.pi * yp * r / c + z = 2 * np.pi * zp * r / c + func = Cos(x) + Cos(y) + Cos(z) impl = ImplicitSurface(function=func) sphere = openmc.Sphere(r=2*L) diff --git a/tests/regression_tests/implicit/squircle/inputs_true.dat b/tests/regression_tests/implicit/squircle/inputs_true.dat index 2aca449f179..84953875c11 100644 --- a/tests/regression_tests/implicit/squircle/inputs_true.dat +++ b/tests/regression_tests/implicit/squircle/inputs_true.dat @@ -10,147 +10,144 @@ - - - - - - + + + + + + -
+ - - - - - - - -
- + + + + + + + + + + + + + + + - - -
-
-
- - - - -
-
-
- - - - - + + + + + + + + + - - - -
-
-
-
-
- -
+ + + + + +
+ + + + + + + + + +
+
+ + -
+ - - - - - - - -
- - - - -
-
-
- - - - -
-
-
- - - - - - - - - -
-
-
-
+
- -
-
-
- -
- - - - - + + + - - -
- - - - -
-
-
+ + - + - -
+
-
- - - - - - - - - -
- - - + + + + + + +
+ + + + + + + + + +
+ + + + + + + + + - -
+ + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+
+
+
- + eigenvalue diff --git a/tests/regression_tests/implicit/squircle/results_true.dat b/tests/regression_tests/implicit/squircle/results_true.dat index 6256abeb109..c7528db777a 100644 --- a/tests/regression_tests/implicit/squircle/results_true.dat +++ b/tests/regression_tests/implicit/squircle/results_true.dat @@ -1,2 +1,2 @@ k-combined: -2.279940E+00 2.272239E-03 +9.325783E-01 6.439983E-03 diff --git a/tests/regression_tests/implicit/squircle/test.py b/tests/regression_tests/implicit/squircle/test.py index a80312accbd..6525151f5d3 100644 --- a/tests/regression_tests/implicit/squircle/test.py +++ b/tests/regression_tests/implicit/squircle/test.py @@ -3,7 +3,7 @@ import numpy as np from openmc.surface import ImplicitSurface -from openmc.implicit import X, Y, Z, Cos, Sqrt +from openmc.implicit import X, Y, Z, Cos, Sqrt, Cached from tests.testing_harness import PyAPITestHarness @@ -15,20 +15,21 @@ def implicit_sphere_model(): material.set_density('g/cm3', 16.0) # Box - x0 = openmc.XPlane(-1., boundary_type="reflective") - x1 = openmc.XPlane(+1., boundary_type="reflective") - y0 = openmc.YPlane(-1., boundary_type="reflective") - y1 = openmc.YPlane(+1., boundary_type="reflective") - z0 = openmc.ZPlane(-1., boundary_type="reflective") - z1 = openmc.ZPlane(+1., boundary_type="reflective") + x0 = openmc.XPlane(-16., boundary_type="vacuum") + x1 = openmc.XPlane(+16., boundary_type="vacuum") + y0 = openmc.YPlane(-16., boundary_type="vacuum") + y1 = openmc.YPlane(+16., boundary_type="vacuum") + z0 = openmc.ZPlane(-16., boundary_type="vacuum") + z1 = openmc.ZPlane(+16., boundary_type="vacuum") box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 # Squircle - x = X() * Sqrt(1 - Y()**2/2 - Z()**2/2 + Y()**2*Z()**2/3) - y = Y() * Sqrt(1 - X()**2/2 - Z()**2/2 + X()**2*Z()**2/3) - z = Z() * Sqrt(1 - X()**2/2 - Y()**2/2 + X()**2*Y()**2/3) - L = 0.5 - xp, yp, zp = 2*np.pi*x/L, 2*np.pi*y/L, 2*np.pi*z/L - func = Cos(xp) + Cos(yp) + Cos(zp) + L = 8.0 + scale = 0.5/L + xp, yp, zp = Cached(scale * X()), Cached(scale * Y()), Cached(scale * Z()) + x = 2 * np.pi * xp * Sqrt(1 - 0.5 * yp**2 - 0.5 * zp**2 + yp**2*zp**2/3) + y = 2 * np.pi * yp * Sqrt(1 - 0.5 * xp**2 - 0.5 * zp**2 + xp**2*zp**2/3) + z = 2 * np.pi * zp * Sqrt(1 - 0.5 * xp**2 - 0.5 * yp**2 + xp**2*yp**2/3) + func = Cos(x) + Cos(y) + Cos(z) impl = ImplicitSurface(function=func) sphere = openmc.Sphere(r=2*L) From 4b77cba76c8a97d5c7838f9133da29d6ce5811ae Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Fri, 10 Jul 2026 17:33:37 -0600 Subject: [PATCH 16/22] Update solver parameter --- docs/source/usersguide/geometry.rst | 6 +++--- include/openmc/implicit_solvers.h | 2 +- src/settings.cpp | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/source/usersguide/geometry.rst b/docs/source/usersguide/geometry.rst index 3e7341321a5..e9289f497a4 100644 --- a/docs/source/usersguide/geometry.rst +++ b/docs/source/usersguide/geometry.rst @@ -454,9 +454,9 @@ be set through :attr:`openmc.Settings.implicit`. rounding can place the particle within the tolerance band of the surface, causing OpenMC's ``sense()`` function to invoke the surface normal for a tiebreak - which may fail for functions with restricted gradient domains. - The default of ``1e-7`` cm is sufficient for all tested geometries:: + The default of ``1e-6`` cm is sufficient for all tested geometries:: - settings.implicit = {'margin': 1e-7} + settings.implicit = {'margin': 1e-6} Increasing ``margin`` slightly can resolve rare geometry errors where a particle appears to bounce back across a surface it just crossed. @@ -468,7 +468,7 @@ All parameters can be combined in a single assignment:: 'atol': 1e-9, 'ftol': 1e-9, 'maxiter': 1000000, - 'margin': 1e-7, + 'margin': 1e-6, } Boundary Conditions diff --git a/include/openmc/implicit_solvers.h b/include/openmc/implicit_solvers.h index fa6f346a7cf..3deeb79a3df 100644 --- a/include/openmc/implicit_solvers.h +++ b/include/openmc/implicit_solvers.h @@ -20,7 +20,7 @@ namespace openmc { class ImplicitSolver { public: - ImplicitSolver(double atol = 1e-8, double ftol = 1e-7, int max_iter = 1000000) + ImplicitSolver(double atol = 1e-9, double ftol = 1e-9, int max_iter = 1000000) : atol_(atol), ftol_(ftol), max_iter_(max_iter) {} virtual ~ImplicitSolver() = default; diff --git a/src/settings.cpp b/src/settings.cpp index a982fee5211..d95d37a971d 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -159,7 +159,7 @@ int implicit_maxiter {1000000}; std::string implicit_solver {"fast"}; double implicit_atol {1.e-9}; double implicit_ftol {1.e-9}; -double implicit_margin {1.e-7}; +double implicit_margin {1.e-6}; } // namespace settings From bbc63536cf23cb6936e2d67cf89c29bb332dc0f5 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Fri, 10 Jul 2026 17:35:29 -0600 Subject: [PATCH 17/22] add clang-format to install README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 735b0e39f5f..8dbb08c8f98 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Developer cross sections : https://anl.box.com/shared/static/teaup95cqv8s9nn56hf ## Quick install ```sh -conda create -n openmc-IF compilers=1.9.0 cmake hdf5 python libpng +conda create -n openmc-IF compilers=1.9.0 cmake hdf5 python libpng clang-format git clone --recurse-submodules git@github.inl.gov:paul-ferney/openmc-dev.git cd openmc git checkout ImplicitFunction From 1dd9fda5c487c9e5908c49961e85d345d72f4302 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Fri, 10 Jul 2026 18:28:28 -0600 Subject: [PATCH 18/22] change install instructions to README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8dbb08c8f98..7b1415b6a27 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ Developer cross sections : https://anl.box.com/shared/static/teaup95cqv8s9nn56hf ```sh conda create -n openmc-IF compilers=1.9.0 cmake hdf5 python libpng clang-format -git clone --recurse-submodules git@github.inl.gov:paul-ferney/openmc-dev.git +git clone --recurse-submodules https://github.com/pferney05/openmc-dev.git cd openmc git checkout ImplicitFunction mkdir build cd build -cmake -DOPENMC_ENABLE_STRICT_FP=on -DOPENMC_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/home/fernpa/miniforge/envs/openmc-IF/ .. +cmake -DOPENMC_ENABLE_STRICT_FP=on -DOPENMC_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/path/to/anaconda/envs/openmc-IF/ .. make -j 12 make install cd .. From d9f382d66944d76d784c304a42247bd71240c5a7 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Fri, 10 Jul 2026 18:58:19 -0600 Subject: [PATCH 19/22] manual clang-format on src --- src/surface.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/surface.cpp b/src/surface.cpp index ff6063bc59d..4c67aecc138 100644 --- a/src/surface.cpp +++ b/src/surface.cpp @@ -1259,7 +1259,7 @@ void read_surfaces(pugi::xml_node node, pugi::xml_node surf_node; int i_surf; for (surf_node = node.child("surface"), i_surf = 0; surf_node; - surf_node = surf_node.next_sibling("surface"), i_surf++) { + surf_node = surf_node.next_sibling("surface"), i_surf++) { std::string surf_type = get_node_value(surf_node, "type", true, true); // Allocate and initialize the new surface From 9986a1255b07befa8d9db64234d6a0ce5a8662cc Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Sat, 11 Jul 2026 11:59:36 -0600 Subject: [PATCH 20/22] Updated test and README.md to pass CI tests --- README.md | 14 ++++++++++++-- .../implicit/diamond/inputs_true.dat | 6 ------ .../implicit/diamond/results_true.dat | 2 +- tests/regression_tests/implicit/diamond/test.py | 1 - .../implicit/fuel_graded/inputs_true.dat | 6 ------ .../implicit/fuel_graded/results_true.dat | 2 +- .../regression_tests/implicit/fuel_graded/test.py | 1 - .../implicit/fuel_graded_no_cache/inputs_true.dat | 6 ------ .../implicit/fuel_graded_no_cache/results_true.dat | 2 +- .../implicit/fuel_graded_no_cache/test.py | 1 - .../implicit/gyroid/inputs_true.dat | 6 ------ .../implicit/gyroid/results_true.dat | 2 +- tests/regression_tests/implicit/gyroid/test.py | 1 - .../implicit/primitive/inputs_true.dat | 6 ------ .../implicit/primitive/results_true.dat | 2 +- tests/regression_tests/implicit/primitive/test.py | 1 - .../implicit/sphere/inputs_true.dat | 6 ------ .../implicit/sphere/results_true.dat | 2 +- tests/regression_tests/implicit/sphere/test.py | 1 - .../implicit/square_circle_fit/inputs_true.dat | 6 ------ .../implicit/square_circle_fit/results_true.dat | 2 +- .../implicit/square_circle_fit/test.py | 1 - .../implicit/squircle/inputs_true.dat | 6 ------ .../implicit/squircle/results_true.dat | 2 +- tests/regression_tests/implicit/squircle/test.py | 1 - 25 files changed, 20 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 7b1415b6a27..0f8e106478e 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,28 @@ Developer cross sections : https://anl.box.com/shared/static/teaup95cqv8s9nn56hf ## Quick install ```sh -conda create -n openmc-IF compilers=1.9.0 cmake hdf5 python libpng clang-format +conda create -n openmc-IF compilers=1.9.0 cmake hdf5 python libpng git clone --recurse-submodules https://github.com/pferney05/openmc-dev.git cd openmc git checkout ImplicitFunction mkdir build cd build -cmake -DOPENMC_ENABLE_STRICT_FP=on -DOPENMC_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=/path/to/anaconda/envs/openmc-IF/ .. +cmake -DOPENMC_ENABLE_STRICT_FP=on -DOPENMC_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=$CONDA_PREFIX .. make -j 12 make install cd .. python -m pip install .[test] ``` +To get git-clang-format: +```sh +conda install clang-format=18.1.8 wget +curl -L \ + https://raw.githubusercontent.com/llvm/llvm-project/llvmorg-18.1.8/clang/tools/clang-format/git-clang-format \ + -o "$CONDA_PREFIX/bin/git-clang-format" +chmod +x "$CONDA_PREFIX/bin/git-clang-format" +./tools/dev/install-commit-hooks.sh +``` + ## Local Notes: - TPMS: max event particles, to be checked - TPMS: Particle could not be located after crossing a boundary of lattice diff --git a/tests/regression_tests/implicit/diamond/inputs_true.dat b/tests/regression_tests/implicit/diamond/inputs_true.dat index 2ee9bc20edc..6e80d2f8f2e 100644 --- a/tests/regression_tests/implicit/diamond/inputs_true.dat +++ b/tests/regression_tests/implicit/diamond/inputs_true.dat @@ -76,11 +76,5 @@ 0.0 0.0 0.0 - - fast - 1e-10 - 1e-10 - 5e-09 - diff --git a/tests/regression_tests/implicit/diamond/results_true.dat b/tests/regression_tests/implicit/diamond/results_true.dat index 575e0958e01..e5cf6cceecd 100644 --- a/tests/regression_tests/implicit/diamond/results_true.dat +++ b/tests/regression_tests/implicit/diamond/results_true.dat @@ -1,2 +1,2 @@ k-combined: -9.372485E-01 4.644406E-03 +9.438052E-01 4.404392E-03 diff --git a/tests/regression_tests/implicit/diamond/test.py b/tests/regression_tests/implicit/diamond/test.py index 23579afdccc..82cc918d7b0 100644 --- a/tests/regression_tests/implicit/diamond/test.py +++ b/tests/regression_tests/implicit/diamond/test.py @@ -36,7 +36,6 @@ def implicit_sphere_model(): settings.particles = 1000 settings.batches = 20 settings.inactive = 5 - settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} model = openmc.Model(settings=settings, geometry=geometry) model.settings.source = openmc.IndependentSource( diff --git a/tests/regression_tests/implicit/fuel_graded/inputs_true.dat b/tests/regression_tests/implicit/fuel_graded/inputs_true.dat index 942947b615f..c17b7770b95 100644 --- a/tests/regression_tests/implicit/fuel_graded/inputs_true.dat +++ b/tests/regression_tests/implicit/fuel_graded/inputs_true.dat @@ -68,11 +68,5 @@ 0.0 0.0 0.0 - - fast - 1e-10 - 1e-10 - 5e-09 -
diff --git a/tests/regression_tests/implicit/fuel_graded/results_true.dat b/tests/regression_tests/implicit/fuel_graded/results_true.dat index bd4ea33ea9b..36cf6e77e45 100644 --- a/tests/regression_tests/implicit/fuel_graded/results_true.dat +++ b/tests/regression_tests/implicit/fuel_graded/results_true.dat @@ -1,2 +1,2 @@ k-combined: -9.381729E-01 6.451632E-03 +9.475677E-01 7.264105E-03 diff --git a/tests/regression_tests/implicit/fuel_graded/test.py b/tests/regression_tests/implicit/fuel_graded/test.py index ad8a10a9dca..cb7ce70f4d0 100644 --- a/tests/regression_tests/implicit/fuel_graded/test.py +++ b/tests/regression_tests/implicit/fuel_graded/test.py @@ -39,7 +39,6 @@ def implicit_sphere_model(): settings.particles = 1000 settings.batches = 20 settings.inactive = 5 - settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} model = openmc.Model(settings=settings, geometry=geometry) model.settings.source = openmc.IndependentSource( diff --git a/tests/regression_tests/implicit/fuel_graded_no_cache/inputs_true.dat b/tests/regression_tests/implicit/fuel_graded_no_cache/inputs_true.dat index 864f50d186d..00bda020e72 100644 --- a/tests/regression_tests/implicit/fuel_graded_no_cache/inputs_true.dat +++ b/tests/regression_tests/implicit/fuel_graded_no_cache/inputs_true.dat @@ -82,11 +82,5 @@ 0.0 0.0 0.0 - - fast - 1e-10 - 1e-10 - 5e-09 - diff --git a/tests/regression_tests/implicit/fuel_graded_no_cache/results_true.dat b/tests/regression_tests/implicit/fuel_graded_no_cache/results_true.dat index bd4ea33ea9b..36cf6e77e45 100644 --- a/tests/regression_tests/implicit/fuel_graded_no_cache/results_true.dat +++ b/tests/regression_tests/implicit/fuel_graded_no_cache/results_true.dat @@ -1,2 +1,2 @@ k-combined: -9.381729E-01 6.451632E-03 +9.475677E-01 7.264105E-03 diff --git a/tests/regression_tests/implicit/fuel_graded_no_cache/test.py b/tests/regression_tests/implicit/fuel_graded_no_cache/test.py index b098676143f..1fd23787f6c 100644 --- a/tests/regression_tests/implicit/fuel_graded_no_cache/test.py +++ b/tests/regression_tests/implicit/fuel_graded_no_cache/test.py @@ -39,7 +39,6 @@ def implicit_sphere_model(): settings.particles = 1000 settings.batches = 20 settings.inactive = 5 - settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} model = openmc.Model(settings=settings, geometry=geometry) model.settings.source = openmc.IndependentSource( diff --git a/tests/regression_tests/implicit/gyroid/inputs_true.dat b/tests/regression_tests/implicit/gyroid/inputs_true.dat index 660019af411..ae5586ad81b 100644 --- a/tests/regression_tests/implicit/gyroid/inputs_true.dat +++ b/tests/regression_tests/implicit/gyroid/inputs_true.dat @@ -80,11 +80,5 @@ 0.0 0.0 0.0 - - fast - 1e-10 - 1e-10 - 5e-09 - diff --git a/tests/regression_tests/implicit/gyroid/results_true.dat b/tests/regression_tests/implicit/gyroid/results_true.dat index 922fd3be108..165aa1d3da4 100644 --- a/tests/regression_tests/implicit/gyroid/results_true.dat +++ b/tests/regression_tests/implicit/gyroid/results_true.dat @@ -1,2 +1,2 @@ k-combined: -2.365224E-01 2.042484E-03 +2.356259E-01 3.813307E-03 diff --git a/tests/regression_tests/implicit/gyroid/test.py b/tests/regression_tests/implicit/gyroid/test.py index da79c60897f..a778f846571 100644 --- a/tests/regression_tests/implicit/gyroid/test.py +++ b/tests/regression_tests/implicit/gyroid/test.py @@ -34,7 +34,6 @@ def implicit_sphere_model(): settings.particles = 1000 settings.batches = 20 settings.inactive = 5 - settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} model = openmc.Model(settings=settings, geometry=geometry) model.settings.source = openmc.IndependentSource( diff --git a/tests/regression_tests/implicit/primitive/inputs_true.dat b/tests/regression_tests/implicit/primitive/inputs_true.dat index 20aace3ff40..b2d249cc105 100644 --- a/tests/regression_tests/implicit/primitive/inputs_true.dat +++ b/tests/regression_tests/implicit/primitive/inputs_true.dat @@ -59,11 +59,5 @@ 0.0 0.0 0.0 - - fast - 1e-10 - 1e-10 - 5e-09 - diff --git a/tests/regression_tests/implicit/primitive/results_true.dat b/tests/regression_tests/implicit/primitive/results_true.dat index 2a9592dc1fc..fccb847b494 100644 --- a/tests/regression_tests/implicit/primitive/results_true.dat +++ b/tests/regression_tests/implicit/primitive/results_true.dat @@ -1,2 +1,2 @@ k-combined: -2.369642E-01 2.281561E-03 +2.370020E-01 3.809965E-03 diff --git a/tests/regression_tests/implicit/primitive/test.py b/tests/regression_tests/implicit/primitive/test.py index 0862b9b7339..a8915558d3a 100644 --- a/tests/regression_tests/implicit/primitive/test.py +++ b/tests/regression_tests/implicit/primitive/test.py @@ -34,7 +34,6 @@ def implicit_sphere_model(): settings.particles = 1000 settings.batches = 20 settings.inactive = 5 - settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} model = openmc.Model(settings=settings, geometry=geometry) model.settings.source = openmc.IndependentSource( diff --git a/tests/regression_tests/implicit/sphere/inputs_true.dat b/tests/regression_tests/implicit/sphere/inputs_true.dat index 06d81baed1e..ddeb9c3b12e 100644 --- a/tests/regression_tests/implicit/sphere/inputs_true.dat +++ b/tests/regression_tests/implicit/sphere/inputs_true.dat @@ -38,11 +38,5 @@ 0.0 0.0 0.0 - - fast - 1e-10 - 1e-10 - 5e-09 - diff --git a/tests/regression_tests/implicit/sphere/results_true.dat b/tests/regression_tests/implicit/sphere/results_true.dat index 8e8a9d2b65a..66032d8b8f9 100644 --- a/tests/regression_tests/implicit/sphere/results_true.dat +++ b/tests/regression_tests/implicit/sphere/results_true.dat @@ -1,2 +1,2 @@ k-combined: -1.009025E+00 5.384496E-03 +1.009025E+00 5.384490E-03 diff --git a/tests/regression_tests/implicit/sphere/test.py b/tests/regression_tests/implicit/sphere/test.py index 9203eeb46b0..682ad5aba7f 100644 --- a/tests/regression_tests/implicit/sphere/test.py +++ b/tests/regression_tests/implicit/sphere/test.py @@ -29,7 +29,6 @@ def implicit_sphere_model(): settings.particles = 1000 settings.batches = 20 settings.inactive = 5 - settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} model = openmc.Model(settings=settings, geometry=geometry) model.settings.source = openmc.IndependentSource( diff --git a/tests/regression_tests/implicit/square_circle_fit/inputs_true.dat b/tests/regression_tests/implicit/square_circle_fit/inputs_true.dat index 8264b28a2f1..6b502f1ad60 100644 --- a/tests/regression_tests/implicit/square_circle_fit/inputs_true.dat +++ b/tests/regression_tests/implicit/square_circle_fit/inputs_true.dat @@ -117,11 +117,5 @@ 0.0 0.0 0.0 - - fast - 1e-10 - 1e-10 - 5e-09 - diff --git a/tests/regression_tests/implicit/square_circle_fit/results_true.dat b/tests/regression_tests/implicit/square_circle_fit/results_true.dat index 400a87fdce1..a362619467a 100644 --- a/tests/regression_tests/implicit/square_circle_fit/results_true.dat +++ b/tests/regression_tests/implicit/square_circle_fit/results_true.dat @@ -1,2 +1,2 @@ k-combined: -8.188301E-01 5.825067E-03 +8.192616E-01 4.877463E-03 diff --git a/tests/regression_tests/implicit/square_circle_fit/test.py b/tests/regression_tests/implicit/square_circle_fit/test.py index c4502374e0f..8a86108160e 100644 --- a/tests/regression_tests/implicit/square_circle_fit/test.py +++ b/tests/regression_tests/implicit/square_circle_fit/test.py @@ -45,7 +45,6 @@ def implicit_sphere_model(): settings.particles = 1000 settings.batches = 20 settings.inactive = 5 - settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} model = openmc.Model(settings=settings, geometry=geometry) model.settings.source = openmc.IndependentSource( diff --git a/tests/regression_tests/implicit/squircle/inputs_true.dat b/tests/regression_tests/implicit/squircle/inputs_true.dat index 84953875c11..b6391637703 100644 --- a/tests/regression_tests/implicit/squircle/inputs_true.dat +++ b/tests/regression_tests/implicit/squircle/inputs_true.dat @@ -159,11 +159,5 @@ 0.0 0.0 0.0 - - fast - 1e-10 - 1e-10 - 5e-09 - diff --git a/tests/regression_tests/implicit/squircle/results_true.dat b/tests/regression_tests/implicit/squircle/results_true.dat index c7528db777a..ea060d88f41 100644 --- a/tests/regression_tests/implicit/squircle/results_true.dat +++ b/tests/regression_tests/implicit/squircle/results_true.dat @@ -1,2 +1,2 @@ k-combined: -9.325783E-01 6.439983E-03 +9.246343E-01 5.728620E-03 diff --git a/tests/regression_tests/implicit/squircle/test.py b/tests/regression_tests/implicit/squircle/test.py index 6525151f5d3..c719d4f3023 100644 --- a/tests/regression_tests/implicit/squircle/test.py +++ b/tests/regression_tests/implicit/squircle/test.py @@ -43,7 +43,6 @@ def implicit_sphere_model(): settings.particles = 1000 settings.batches = 20 settings.inactive = 5 - settings.implicit = {"name": "fast", "atol":1e-10, "ftol":1e-10, "margin":5e-9} model = openmc.Model(settings=settings, geometry=geometry) model.settings.source = openmc.IndependentSource( From 2fa19760f30f9f7dd9cef3427222c0045e79de73 Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Sat, 11 Jul 2026 12:30:39 -0600 Subject: [PATCH 21/22] Renamed regression tests for more clarity --- tests/regression_tests/implicit/diamond/test.py | 6 +++--- tests/regression_tests/implicit/fuel_graded/test.py | 6 +++--- .../regression_tests/implicit/fuel_graded_no_cache/test.py | 6 +++--- tests/regression_tests/implicit/gyroid/test.py | 6 +++--- tests/regression_tests/implicit/primitive/test.py | 6 +++--- tests/regression_tests/implicit/sphere_classic/test.py | 6 +++--- tests/regression_tests/implicit/square_circle_fit/test.py | 6 +++--- tests/regression_tests/implicit/squircle/test.py | 6 +++--- 8 files changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/regression_tests/implicit/diamond/test.py b/tests/regression_tests/implicit/diamond/test.py index 82cc918d7b0..ec29a809e49 100644 --- a/tests/regression_tests/implicit/diamond/test.py +++ b/tests/regression_tests/implicit/diamond/test.py @@ -7,7 +7,7 @@ @pytest.fixture() -def implicit_sphere_model(): +def implicit_diamond_model(): # Material material = openmc.Material() material.add_nuclide('U235', 5.0) @@ -44,6 +44,6 @@ def implicit_sphere_model(): return model -def test_implicit_sphere(implicit_sphere_model): - harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) +def test_implicit_diamond(implicit_diamond_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_diamond_model) harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/fuel_graded/test.py b/tests/regression_tests/implicit/fuel_graded/test.py index cb7ce70f4d0..3acd9262487 100644 --- a/tests/regression_tests/implicit/fuel_graded/test.py +++ b/tests/regression_tests/implicit/fuel_graded/test.py @@ -8,7 +8,7 @@ @pytest.fixture() -def implicit_sphere_model(): +def implicit_fuel_graded_model(): # Material material = openmc.Material() material.add_nuclide('U235', 1.0) @@ -47,6 +47,6 @@ def implicit_sphere_model(): return model -def test_implicit_sphere(implicit_sphere_model): - harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) +def test_implicit_fuel_graded(implicit_fuel_graded_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_fuel_graded_model) harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/fuel_graded_no_cache/test.py b/tests/regression_tests/implicit/fuel_graded_no_cache/test.py index 1fd23787f6c..afe755c8d1f 100644 --- a/tests/regression_tests/implicit/fuel_graded_no_cache/test.py +++ b/tests/regression_tests/implicit/fuel_graded_no_cache/test.py @@ -8,7 +8,7 @@ @pytest.fixture() -def implicit_sphere_model(): +def implicit_fuel_graded_nocache_model(): # Material material = openmc.Material() material.add_nuclide('U235', 1.0) @@ -47,6 +47,6 @@ def implicit_sphere_model(): return model -def test_implicit_sphere(implicit_sphere_model): - harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) +def test_implicit_fuel_graded_nocache(implicit_fuel_graded_nocache_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_fuel_graded_nocache_model) harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/gyroid/test.py b/tests/regression_tests/implicit/gyroid/test.py index a778f846571..6bb9ea27782 100644 --- a/tests/regression_tests/implicit/gyroid/test.py +++ b/tests/regression_tests/implicit/gyroid/test.py @@ -7,7 +7,7 @@ @pytest.fixture() -def implicit_sphere_model(): +def implicit_gyroid_model(): # Material material = openmc.Material() material.add_nuclide('U235', 5.0) @@ -42,6 +42,6 @@ def implicit_sphere_model(): return model -def test_implicit_sphere(implicit_sphere_model): - harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) +def test_implicit_gyroid(implicit_gyroid_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_gyroid_model) harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/primitive/test.py b/tests/regression_tests/implicit/primitive/test.py index a8915558d3a..50860b77ac7 100644 --- a/tests/regression_tests/implicit/primitive/test.py +++ b/tests/regression_tests/implicit/primitive/test.py @@ -7,7 +7,7 @@ @pytest.fixture() -def implicit_sphere_model(): +def implicit_primitive_model(): # Material material = openmc.Material() material.add_nuclide('U235', 5.0) @@ -42,6 +42,6 @@ def implicit_sphere_model(): return model -def test_implicit_sphere(implicit_sphere_model): - harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) +def test_implicit_primitive(implicit_primitive_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_primitive_model) harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/sphere_classic/test.py b/tests/regression_tests/implicit/sphere_classic/test.py index 1339dfff938..9107096463b 100644 --- a/tests/regression_tests/implicit/sphere_classic/test.py +++ b/tests/regression_tests/implicit/sphere_classic/test.py @@ -7,7 +7,7 @@ @pytest.fixture() -def implicit_sphere_model(): +def classic_sphere_model(): # Material material = openmc.Material() material.add_nuclide('U235', 1.0) @@ -34,6 +34,6 @@ def implicit_sphere_model(): return model -def test_implicit_sphere(implicit_sphere_model): - harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) +def test_classic_sphere(classic_sphere_model): + harness = PyAPITestHarness('statepoint.20.h5', model=classic_sphere_model) harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/square_circle_fit/test.py b/tests/regression_tests/implicit/square_circle_fit/test.py index 8a86108160e..9e4e5577d81 100644 --- a/tests/regression_tests/implicit/square_circle_fit/test.py +++ b/tests/regression_tests/implicit/square_circle_fit/test.py @@ -8,7 +8,7 @@ @pytest.fixture() -def implicit_sphere_model(): +def implicit_square_circle_fit_model(): # Material material = openmc.Material() material.add_nuclide('U235', 1.0) @@ -53,6 +53,6 @@ def implicit_sphere_model(): return model -def test_implicit_sphere(implicit_sphere_model): - harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) +def test_implicit_square_circle_fit(implicit_square_circle_fit_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_square_circle_fit_model) harness.main() \ No newline at end of file diff --git a/tests/regression_tests/implicit/squircle/test.py b/tests/regression_tests/implicit/squircle/test.py index c719d4f3023..19d17ebe0b6 100644 --- a/tests/regression_tests/implicit/squircle/test.py +++ b/tests/regression_tests/implicit/squircle/test.py @@ -8,7 +8,7 @@ @pytest.fixture() -def implicit_sphere_model(): +def implicit_squircle_model(): # Material material = openmc.Material() material.add_nuclide('U235', 1.0) @@ -51,6 +51,6 @@ def implicit_sphere_model(): return model -def test_implicit_sphere(implicit_sphere_model): - harness = PyAPITestHarness('statepoint.20.h5', model=implicit_sphere_model) +def test_implicit_squircle(implicit_squircle_model): + harness = PyAPITestHarness('statepoint.20.h5', model=implicit_squircle_model) harness.main() \ No newline at end of file From 6125408721ff679fa14508933f0337c6b368a3bd Mon Sep 17 00:00:00 2001 From: paul-ferney Date: Sat, 11 Jul 2026 14:38:33 -0600 Subject: [PATCH 22/22] updated Diamond Test to match paper example --- .../implicit/diamond/inputs_true.dat | 18 +++++++++--------- .../implicit/diamond/results_true.dat | 2 +- .../regression_tests/implicit/diamond/test.py | 18 ++++++++---------- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/tests/regression_tests/implicit/diamond/inputs_true.dat b/tests/regression_tests/implicit/diamond/inputs_true.dat index 6e80d2f8f2e..693ee5bc9b8 100644 --- a/tests/regression_tests/implicit/diamond/inputs_true.dat +++ b/tests/regression_tests/implicit/diamond/inputs_true.dat @@ -10,12 +10,12 @@ - - - - - - + + + + + + @@ -26,7 +26,7 @@ - + @@ -37,7 +37,7 @@ - + @@ -45,7 +45,7 @@ - + diff --git a/tests/regression_tests/implicit/diamond/results_true.dat b/tests/regression_tests/implicit/diamond/results_true.dat index e5cf6cceecd..a9144eca5e8 100644 --- a/tests/regression_tests/implicit/diamond/results_true.dat +++ b/tests/regression_tests/implicit/diamond/results_true.dat @@ -1,2 +1,2 @@ k-combined: -9.438052E-01 4.404392E-03 +2.372756E-01 1.317173E-03 diff --git a/tests/regression_tests/implicit/diamond/test.py b/tests/regression_tests/implicit/diamond/test.py index ec29a809e49..329004f07ce 100644 --- a/tests/regression_tests/implicit/diamond/test.py +++ b/tests/regression_tests/implicit/diamond/test.py @@ -15,17 +15,15 @@ def implicit_diamond_model(): material.set_density('g/cm3', 16.0) # Diamond - x0 = openmc.XPlane(-0.5, boundary_type="periodic") - x1 = openmc.XPlane(+0.5, boundary_type="periodic") - y0 = openmc.YPlane(-0.5, boundary_type="periodic") - y1 = openmc.YPlane(+0.5, boundary_type="periodic") - z0 = openmc.ZPlane(-0.5, boundary_type="periodic") - z1 = openmc.ZPlane(+0.5, boundary_type="periodic") - x0.periodic_surface = x1 - y0.periodic_surface = y1 - z0.periodic_surface = z1 + x0 = openmc.XPlane(-16.0, boundary_type="vacuum") + x1 = openmc.XPlane(+16.0, boundary_type="vacuum") + y0 = openmc.YPlane(-16.0, boundary_type="vacuum") + y1 = openmc.YPlane(+16.0, boundary_type="vacuum") + z0 = openmc.ZPlane(-16.0, boundary_type="vacuum") + z1 = openmc.ZPlane(+16.0, boundary_type="vacuum") box = +x0 & -x1 & +y0 & -y1 & +z0 & -z1 - impl = openmc.TPMS.from_pitch_isovalue("diamond", 1., 0.) + + impl = openmc.TPMS.from_pitch_isovalue("diamond", 8., 0.) fuel_cell = openmc.Cell(region=-impl & box, fill=material) void_cell = openmc.Cell(region=+impl & box)