processforge 0.3.10__tar.gz → 0.3.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. {processforge-0.3.10/src/processforge.egg-info → processforge-0.3.11}/PKG-INFO +1 -1
  2. {processforge-0.3.10 → processforge-0.3.11}/pyproject.toml +1 -1
  3. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/openmc_provider.py +291 -164
  4. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/schemas/openmc/openmc_model.py +33 -2
  5. {processforge-0.3.10 → processforge-0.3.11/src/processforge.egg-info}/PKG-INFO +1 -1
  6. {processforge-0.3.10 → processforge-0.3.11}/src/processforge.egg-info/SOURCES.txt +1 -2
  7. processforge-0.3.11/tests/test_openmc_provider.py +524 -0
  8. processforge-0.3.10/flowsheets/custom/crimson/MUWS_HB.json +0 -216
  9. processforge-0.3.10/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -202
  10. {processforge-0.3.10 → processforge-0.3.11}/LICENSE +0 -0
  11. {processforge-0.3.10 → processforge-0.3.11}/MANIFEST.in +0 -0
  12. {processforge-0.3.10 → processforge-0.3.11}/README.md +0 -0
  13. {processforge-0.3.10 → processforge-0.3.11}/flowsheets/closed-loop-chain.json +0 -0
  14. {processforge-0.3.10 → processforge-0.3.11}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
  15. {processforge-0.3.10 → processforge-0.3.11}/flowsheets/hydraulic-chain.json +0 -0
  16. {processforge-0.3.10 → processforge-0.3.11}/flowsheets/openmc/msre_eigenvalue.json +0 -0
  17. {processforge-0.3.10 → processforge-0.3.11}/setup.cfg +0 -0
  18. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/__init__.py +0 -0
  19. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/__main__.py +0 -0
  20. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/_schema.py +0 -0
  21. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/analysis/__init__.py +0 -0
  22. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/analysis/dof.py +0 -0
  23. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/api/__init__.py +0 -0
  24. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/api/serve.py +0 -0
  25. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/cli/__init__.py +0 -0
  26. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/cli/apply.py +0 -0
  27. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/cli/common.py +0 -0
  28. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/cli/diagram.py +0 -0
  29. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/cli/display.py +0 -0
  30. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/cli/export_fmu.py +0 -0
  31. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/cli/export_modelica.py +0 -0
  32. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/cli/init.py +0 -0
  33. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/cli/plan.py +0 -0
  34. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/cli/run.py +0 -0
  35. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/cli/validate.py +0 -0
  36. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/compose.py +0 -0
  37. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/__init__.py +0 -0
  38. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/backends/__init__.py +0 -0
  39. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/backends/base.py +0 -0
  40. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/backends/casadi_backend.py +0 -0
  41. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  42. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/backends/scipy_backend.py +0 -0
  43. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/flowsheet.py +0 -0
  44. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/jacobian.py +0 -0
  45. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/mixin.py +0 -0
  46. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/solver.py +0 -0
  47. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/stream_var.py +0 -0
  48. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/units/__init__.py +0 -0
  49. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/units/flash_eo.py +0 -0
  50. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/units/heater_eo.py +0 -0
  51. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/units/pipes_eo.py +0 -0
  52. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/units/pump_eo.py +0 -0
  53. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/units/strainer_eo.py +0 -0
  54. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/eo/units/valve_eo.py +0 -0
  55. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/flowsheet.py +0 -0
  56. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/fmu/__init__.py +0 -0
  57. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/fmu/_fmi_vars.py +0 -0
  58. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/fmu/builder.py +0 -0
  59. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/fmu/slave_template.py +0 -0
  60. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/lock.py +0 -0
  61. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/modelica/__init__.py +0 -0
  62. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/modelica/mo_writer.py +0 -0
  63. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/modelica/omc_runner.py +0 -0
  64. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/modelica/transpiler.py +0 -0
  65. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/modelica/unit_equations.py +0 -0
  66. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/pcl/__init__.py +0 -0
  67. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/pcl/compiler.py +0 -0
  68. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/pcl/namespace.py +0 -0
  69. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/provenance.py +0 -0
  70. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/__init__.py +0 -0
  71. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/base.py +0 -0
  72. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/base_jacobian_mixin.py +0 -0
  73. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/cantera_jacobian.py +0 -0
  74. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/cantera_provider.py +0 -0
  75. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/container_client.py +0 -0
  76. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/coolprop_provider.py +0 -0
  77. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/festim_provider.py +0 -0
  78. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/jacobian_contributor.py +0 -0
  79. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/manager.py +0 -0
  80. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/modelica_jacobian.py +0 -0
  81. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/modelica_provider.py +0 -0
  82. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/reference_state_registry.py +0 -0
  83. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/providers/registry.py +0 -0
  84. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/result.py +0 -0
  85. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/result_schema.py +0 -0
  86. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/schemas/__init__.py +0 -0
  87. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/schemas/flowsheet_schema.json +0 -0
  88. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/simulate.py +0 -0
  89. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/solver.py +0 -0
  90. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/state.py +0 -0
  91. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/thermo.py +0 -0
  92. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/types.py +0 -0
  93. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/__init__.py +0 -0
  94. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/base.py +0 -0
  95. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/cstr.py +0 -0
  96. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/flash.py +0 -0
  97. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/heater.py +0 -0
  98. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/pfr.py +0 -0
  99. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/pipes.py +0 -0
  100. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/pump.py +0 -0
  101. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/registry.py +0 -0
  102. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/solver_unit.py +0 -0
  103. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/strainer.py +0 -0
  104. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/tank.py +0 -0
  105. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/units/valve.py +0 -0
  106. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/utils/__init__.py +0 -0
  107. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/utils/flowsheet_diagram.py +0 -0
  108. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/utils/mermaid_diagram.py +0 -0
  109. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/utils/s3_upload.py +0 -0
  110. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/utils/topology.py +0 -0
  111. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/utils/unit_consistency.py +0 -0
  112. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/utils/validate_flowsheet.py +0 -0
  113. {processforge-0.3.10 → processforge-0.3.11}/src/processforge/utils/validation.py +0 -0
  114. {processforge-0.3.10 → processforge-0.3.11}/src/processforge.egg-info/dependency_links.txt +0 -0
  115. {processforge-0.3.10 → processforge-0.3.11}/src/processforge.egg-info/entry_points.txt +0 -0
  116. {processforge-0.3.10 → processforge-0.3.11}/src/processforge.egg-info/requires.txt +0 -0
  117. {processforge-0.3.10 → processforge-0.3.11}/src/processforge.egg-info/top_level.txt +0 -0
  118. {processforge-0.3.10 → processforge-0.3.11}/tests/test_cli.py +0 -0
  119. {processforge-0.3.10 → processforge-0.3.11}/tests/test_festim_provider.py +0 -0
  120. {processforge-0.3.10 → processforge-0.3.11}/tests/test_init.py +0 -0
  121. {processforge-0.3.10 → processforge-0.3.11}/tests/test_providers.py +0 -0
  122. {processforge-0.3.10 → processforge-0.3.11}/tests/test_s3_upload.py +0 -0
  123. {processforge-0.3.10 → processforge-0.3.11}/tests/test_state.py +0 -0
  124. {processforge-0.3.10 → processforge-0.3.11}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.3.10
3
+ Version: 0.3.11
4
4
  Summary: A Python-based process simulation framework for chemical engineering applications.
5
5
  Author-email: Processforge Team <team@processforge.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "processforge"
7
- version = "0.3.10"
7
+ version = "0.3.11"
8
8
  description = "A Python-based process simulation framework for chemical engineering applications."
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -38,6 +38,7 @@ import math
38
38
  import os
39
39
  import pathlib
40
40
  import tempfile
41
+ import threading
41
42
  from abc import ABC, abstractmethod
42
43
  from typing import TYPE_CHECKING, Optional
43
44
 
@@ -45,7 +46,12 @@ from loguru import logger
45
46
 
46
47
  from .base import AbstractProvider
47
48
  from .registry import register_provider
48
- from processforge.schemas.openmc.openmc_model import MeshTallyConfig, SolverConfig, SourcePoint
49
+ from processforge.schemas.openmc.openmc_model import (
50
+ MeshTallyConfig,
51
+ RunMode,
52
+ SolverConfig,
53
+ SourcePoint,
54
+ )
49
55
 
50
56
 
51
57
  def _resolve_omc_path(path: Optional[str]) -> Optional[str]:
@@ -54,6 +60,7 @@ def _resolve_omc_path(path: Optional[str]) -> Optional[str]:
54
60
  return None
55
61
  return os.path.expandvars(path)
56
62
 
63
+
57
64
  if TYPE_CHECKING:
58
65
  from processforge.types import (
59
66
  FlowsheetConfig,
@@ -68,10 +75,18 @@ if TYPE_CHECKING:
68
75
  OpenMCSolverConfig = SolverConfig
69
76
 
70
77
 
78
+ # Serializes OpenMC runs. Every run mutates process-global state (the
79
+ # OPENMC_CROSS_SECTIONS env var read by the spawned `openmc` subprocess), so
80
+ # concurrent runs sharing a process — e.g. the provider HTTP server threadpool —
81
+ # must not interleave.
82
+ _OPENMC_RUN_LOCK = threading.Lock()
83
+
84
+
71
85
  # ---------------------------------------------------------------------------
72
86
  # Build helpers
73
87
  # ---------------------------------------------------------------------------
74
88
 
89
+
75
90
  class OpenMCBuildHelpers:
76
91
  """Shared OpenMC model-building utilities injected into sim strategies.
77
92
 
@@ -150,7 +165,13 @@ class OpenMCBuildHelpers:
150
165
  settings.batches = solver_cfg.batches
151
166
  settings.inactive = solver_cfg.inactive
152
167
  settings.particles = solver_cfg.particles
153
- settings.run_mode = solver_cfg.run_mode
168
+ # `run_mode` may be a RunMode enum (validated config) or a raw string
169
+ # (strategies apply it via `model_copy(update=...)`, which pydantic v2
170
+ # does not re-validate). Normalise to a plain string for OpenMC.
171
+ run_mode = solver_cfg.run_mode
172
+ settings.run_mode = (
173
+ run_mode.value if isinstance(run_mode, RunMode) else str(run_mode)
174
+ )
154
175
  settings.source = [source]
155
176
  if solver_cfg.temperature_default is not None:
156
177
  settings.temperature = {"default": solver_cfg.temperature_default}
@@ -161,6 +182,7 @@ class OpenMCBuildHelpers:
161
182
  # Simulation type strategy registry
162
183
  # ---------------------------------------------------------------------------
163
184
 
185
+
164
186
  class OpenMCSimStrategy(ABC):
165
187
  """Base class for an OpenMC simulation type.
166
188
 
@@ -227,14 +249,18 @@ def get_registered_sim_types() -> dict[str, type]:
227
249
  # Built-in strategies
228
250
  # ---------------------------------------------------------------------------
229
251
 
230
- class _FixedSourcePointStrategy(OpenMCSimStrategy):
231
- """Fixed-source point-source approximation using a simple CSG sphere geometry.
232
252
 
233
- No DAGMC file required. Models the plant as a single point source inside
234
- a homogeneous sphere of a given radius and material — useful for broad-scale
235
- dose/flux estimates where full CAD geometry is not needed.
253
+ class _PointSourceSphereStrategy(OpenMCSimStrategy):
254
+ """Shared build logic for CSG-sphere point-source simulations.
255
+
256
+ Models the plant as a single point source inside a homogeneous sphere of a
257
+ given radius and material. Subclasses differ only in ``sim_type``
258
+ (error/source-seed strings) and ``run_mode``. No DAGMC file required.
236
259
  """
237
260
 
261
+ sim_type: str = "point_source_csg"
262
+ run_mode: str = "eigenvalue"
263
+
238
264
  def build(
239
265
  self,
240
266
  openmc,
@@ -244,7 +270,7 @@ class _FixedSourcePointStrategy(OpenMCSimStrategy):
244
270
  ) -> tuple:
245
271
  if solver_cfg.source_point is None:
246
272
  raise ValueError(
247
- "sim_type='fixed_source_point' requires 'source_point' in solver_config"
273
+ f"sim_type='{self.sim_type}' requires 'source_point' in solver_config"
248
274
  )
249
275
 
250
276
  fill_name = solver_cfg.point_source_material
@@ -256,24 +282,42 @@ class _FixedSourcePointStrategy(OpenMCSimStrategy):
256
282
 
257
283
  omc_materials = openmc.Materials(list(materials_map.values()))
258
284
 
259
- sphere = openmc.Sphere(r=solver_cfg.point_source_sphere_radius, boundary_type="vacuum")
285
+ sphere = openmc.Sphere(
286
+ r=solver_cfg.point_source_sphere_radius, boundary_type="vacuum"
287
+ )
260
288
  fill_mat = materials_map[fill_name] if fill_name else None
261
- geometry = openmc.Geometry([
262
- openmc.Cell(fill=fill_mat, region=-sphere),
263
- openmc.Cell(region=+sphere),
264
- ])
289
+ geometry = openmc.Geometry(
290
+ [
291
+ openmc.Cell(fill=fill_mat, region=-sphere),
292
+ openmc.Cell(region=+sphere),
293
+ ]
294
+ )
265
295
 
266
296
  source = helpers.build_point_source(openmc, solver_cfg.source_point)
267
- fixed_cfg = solver_cfg.model_copy(update={"run_mode": "fixed source"})
268
- settings = helpers.build_settings(openmc, fixed_cfg, source)
297
+ run_cfg = solver_cfg.model_copy(update={"run_mode": self.run_mode})
298
+ settings = helpers.build_settings(openmc, run_cfg, source)
269
299
 
270
- tally_objs = [helpers.build_mesh_tally(openmc, t) for t in solver_cfg.mesh_tallies]
300
+ tally_objs = [
301
+ helpers.build_mesh_tally(openmc, t) for t in solver_cfg.mesh_tallies
302
+ ]
271
303
  tallies = openmc.Tallies(tally_objs) if tally_objs else openmc.Tallies()
272
304
 
273
305
  return omc_materials, geometry, settings, tallies
274
306
 
275
307
 
276
- class _EigenvalueCSGStrategy(OpenMCSimStrategy):
308
+ class _FixedSourcePointStrategy(_PointSourceSphereStrategy):
309
+ """Fixed-source point-source approximation using a simple CSG sphere geometry.
310
+
311
+ No DAGMC file required. Models the plant as a single point source inside
312
+ a homogeneous sphere of a given radius and material — useful for broad-scale
313
+ dose/flux estimates where full CAD geometry is not needed.
314
+ """
315
+
316
+ sim_type = "fixed_source_point"
317
+ run_mode = "fixed source"
318
+
319
+
320
+ class _EigenvalueCSGStrategy(_PointSourceSphereStrategy):
277
321
  """Eigenvalue (criticality) simulation using a simple CSG sphere geometry.
278
322
 
279
323
  Computes k_eff for a homogeneous sphere of fissile material. The initial
@@ -284,42 +328,8 @@ class _EigenvalueCSGStrategy(OpenMCSimStrategy):
284
328
  salt or other homogeneous fissile compositions.
285
329
  """
286
330
 
287
- def build(
288
- self,
289
- openmc,
290
- solver_cfg: SolverConfig,
291
- materials_map: dict,
292
- helpers: OpenMCBuildHelpers,
293
- ) -> tuple:
294
- if solver_cfg.source_point is None:
295
- raise ValueError(
296
- "sim_type='eigenvalue_csg' requires 'source_point' in solver_config"
297
- )
298
-
299
- fill_name = solver_cfg.point_source_material
300
- if fill_name is not None and fill_name not in materials_map:
301
- raise ValueError(
302
- f"point_source_material='{fill_name}' not found in materials map. "
303
- f"Available: {sorted(materials_map)}"
304
- )
305
-
306
- omc_materials = openmc.Materials(list(materials_map.values()))
307
-
308
- sphere = openmc.Sphere(r=solver_cfg.point_source_sphere_radius, boundary_type="vacuum")
309
- fill_mat = materials_map[fill_name] if fill_name else None
310
- geometry = openmc.Geometry([
311
- openmc.Cell(fill=fill_mat, region=-sphere),
312
- openmc.Cell(region=+sphere),
313
- ])
314
-
315
- source = helpers.build_point_source(openmc, solver_cfg.source_point)
316
- eigenvalue_cfg = solver_cfg.model_copy(update={"run_mode": "eigenvalue"})
317
- settings = helpers.build_settings(openmc, eigenvalue_cfg, source)
318
-
319
- tally_objs = [helpers.build_mesh_tally(openmc, t) for t in solver_cfg.mesh_tallies]
320
- tallies = openmc.Tallies(tally_objs) if tally_objs else openmc.Tallies()
321
-
322
- return omc_materials, geometry, settings, tallies
331
+ sim_type = "eigenvalue_csg"
332
+ run_mode = "eigenvalue"
323
333
 
324
334
 
325
335
  # Register built-ins at module load
@@ -331,15 +341,16 @@ register_openmc_sim_type("eigenvalue_csg", _EigenvalueCSGStrategy)
331
341
  # Material validation constants
332
342
  # ---------------------------------------------------------------------------
333
343
 
334
- _VALID_DENSITY_UNITS = frozenset({
335
- "g/cm3", "g/cc", "kg/m3", "atom/b-cm", "atom/cm3", "sum", "macro"
336
- })
344
+ _VALID_DENSITY_UNITS = frozenset(
345
+ {"g/cm3", "g/cc", "kg/m3", "atom/b-cm", "atom/cm3", "sum", "macro"}
346
+ )
337
347
 
338
348
 
339
349
  # ---------------------------------------------------------------------------
340
350
  # OpenMCProvider
341
351
  # ---------------------------------------------------------------------------
342
352
 
353
+
343
354
  class OpenMCProvider(AbstractProvider):
344
355
  """Monte Carlo neutronics provider backed by OpenMC.
345
356
 
@@ -347,18 +358,22 @@ class OpenMCProvider(AbstractProvider):
347
358
  ----------------
348
359
  * **Material registry** — ``initialize()`` loads all material defs from the
349
360
  flowsheet and indexes them by ``id`` and name.
350
- * **Working-directory management** — OpenMC writes XML and HDF5 output to
351
- the current working directory. The provider changes CWD to
352
- ``<provider_output_dir>`` before each run and
353
- restores it afterwards in a ``finally`` block.
361
+ * **Working-directory management** — each run executes in
362
+ ``<provider_output_dir>`` via ``openmc.model.Model.run(cwd=...)`` no
363
+ process-wide ``chdir``. Falls back to a temp dir when the configured
364
+ output dir is not writable.
354
365
  * **Cross-section override** — If ``provider_config.cross_sections`` or
355
366
  ``solver_cfg.cross_sections`` is set, ``OPENMC_CROSS_SECTIONS`` is
356
- temporarily overridden.
367
+ temporarily overridden. Runs are serialized behind a module-wide lock so
368
+ the process-global env var cannot race across concurrent requests.
357
369
  * **Simulation dispatch** — ``run_simulation()`` looks up the strategy from
358
- ``_SIM_TYPE_REGISTRY`` by ``sim_type``, calls ``strategy.build()``, exports
359
- XML, calls ``openmc.run()``, then parses the statepoint file.
370
+ ``_SIM_TYPE_REGISTRY`` by ``sim_type``, calls ``strategy.build()``, runs
371
+ via ``Model.run(cwd=...)``, then parses the returned statepoint.
372
+ Failures are returned as ``SimulationResult(status="failed")`` with
373
+ ``run_dir`` and error metadata rather than silently bubbling up.
360
374
  * **Result extraction** — extracts ``k_eff`` (eigenvalue mode) and per-tally
361
- aggregate scalars (mean totals, representative std devs).
375
+ aggregate scalars (mean totals, representative std devs). Extraction
376
+ issues surface in ``metadata["tally_warnings"]``.
362
377
  """
363
378
 
364
379
  def __init__(self):
@@ -392,10 +407,31 @@ class OpenMCProvider(AbstractProvider):
392
407
  out_dir = os.path.join(root, out_dir)
393
408
  self._provider_output_dir = out_dir
394
409
  self._cross_sections = provider_config.cross_sections
395
- self._materials = {}
396
410
 
411
+ # Material IDs must be unique — duplicate IDs were previously dropped
412
+ # silently, causing missing-material KeyErrors deep in the run.
413
+ id_to_name: dict = {}
414
+ duplicates = []
397
415
  for mat_name, mat_def in flowsheet_config.materials.items():
398
- self._materials[mat_name] = mat_def
416
+ mid = mat_def.id
417
+ if mid in id_to_name:
418
+ duplicates.append(
419
+ f"id={mid} shared by '{id_to_name[mid]}' and '{mat_name}'"
420
+ )
421
+ else:
422
+ id_to_name[mid] = mat_name
423
+ if duplicates:
424
+ raise ValueError(
425
+ "Flowsheet materials have duplicate OpenMC ids: "
426
+ + "; ".join(duplicates)
427
+ )
428
+
429
+ # Fail fast on a bad cross-section path instead of deep inside openmc.
430
+ resolved_xs = _resolve_omc_path(self._cross_sections)
431
+ if resolved_xs:
432
+ self._check_cross_sections_path(resolved_xs)
433
+
434
+ self._materials = dict(flowsheet_config.materials.items())
399
435
 
400
436
  self._initialized = True
401
437
  n_mats = len({v.id for v in self._materials.values()})
@@ -405,7 +441,9 @@ class OpenMCProvider(AbstractProvider):
405
441
  )
406
442
 
407
443
  def get_thermo_properties(self, stream: dict) -> dict:
408
- raise NotImplementedError("OpenMCProvider does not support stream thermodynamics.")
444
+ raise NotImplementedError(
445
+ "OpenMCProvider does not support stream thermodynamics."
446
+ )
409
447
 
410
448
  def compute_unit(self, unit_type: str, config: dict, inlet: dict):
411
449
  """Return ``None`` — OpenMC uses ``run_simulation`` via ``SolverUnit``."""
@@ -421,18 +459,22 @@ class OpenMCProvider(AbstractProvider):
421
459
 
422
460
  Rules
423
461
  -----
424
- * ``density`` and ``density_units`` are required.
462
+ * ``density`` is required unless ``density_units`` is ``"sum"``.
425
463
  * ``density_units`` must be one of the OpenMC-recognised strings.
426
464
  * At least one of ``nuclides`` or ``elements`` must be non-empty.
465
+ * Each ``nuclides`` entry needs ``name`` + numeric ``percent``;
466
+ each ``elements`` entry needs ``element`` + numeric ``percent``.
467
+ * ``percent_type`` must be ``"ao"`` or ``"wo"``.
427
468
 
428
469
  Returns:
429
470
  List of error strings (empty = valid).
430
471
  """
431
472
  errors = []
432
473
 
433
- if mat_def.density is None:
474
+ if mat_def.density is None and mat_def.density_units != "sum":
434
475
  errors.append(
435
- f"Material '{mat_name}' is missing 'density' (required for OpenMC)."
476
+ f"Material '{mat_name}' is missing 'density' (required for OpenMC; "
477
+ "only 'sum' density_units may omit it)."
436
478
  )
437
479
  if mat_def.density_units is None:
438
480
  errors.append(
@@ -452,22 +494,62 @@ class OpenMCProvider(AbstractProvider):
452
494
  "Add at least one 'nuclides' or 'elements' entry."
453
495
  )
454
496
 
497
+ errors.extend(
498
+ cls._validate_component_entries(mat_name, "nuclides", mat_def.nuclides)
499
+ )
500
+ errors.extend(
501
+ cls._validate_component_entries(
502
+ mat_name, "elements", mat_def.extra.get("elements", [])
503
+ )
504
+ )
505
+
455
506
  return errors
456
507
 
457
- def run_simulation(self, unit_config: "UnitConfig", inlet: dict) -> "SimulationResult":
508
+ @staticmethod
509
+ def _validate_component_entries(mat_name: str, kind: str, entries: list) -> list:
510
+ """Validate each nuclide/element component entry in a material."""
511
+ label_key = "name" if kind == "nuclides" else "element"
512
+ errors = []
513
+ for i, entry in enumerate(entries or []):
514
+ if not isinstance(entry, dict):
515
+ errors.append(
516
+ f"Material '{mat_name}' {kind}[{i}] must be an object, "
517
+ f"got {type(entry).__name__}."
518
+ )
519
+ continue
520
+ if not entry.get(label_key):
521
+ errors.append(
522
+ f"Material '{mat_name}' {kind}[{i}] is missing '{label_key}'."
523
+ )
524
+ if not isinstance(entry.get("percent"), (int, float)):
525
+ errors.append(
526
+ f"Material '{mat_name}' {kind}[{i}] must have a numeric 'percent'."
527
+ )
528
+ ptype = entry.get("percent_type", "ao")
529
+ if ptype not in ("ao", "wo"):
530
+ errors.append(
531
+ f"Material '{mat_name}' {kind}[{i}] percent_type must be "
532
+ f"'ao' or 'wo', got '{ptype}'."
533
+ )
534
+ return errors
535
+
536
+ def run_simulation(
537
+ self, unit_config: "UnitConfig", inlet: dict
538
+ ) -> "SimulationResult":
458
539
  """Build and run an OpenMC simulation from a typed ``UnitConfig``.
459
540
 
460
541
  Execution flow
461
542
  --------------
462
- 1. Parse ``solver_config`` → ``OpenMCSolverConfig``
543
+ 1. Parse ``solver_config`` → ``SolverConfig``
463
544
  2. Look up strategy from ``_SIM_TYPE_REGISTRY``
464
545
  3. Build ``openmc.Material`` objects for all materials in the registry
465
546
  4. Call ``strategy.build()`` → ``(materials, geometry, settings, tallies)``
466
- 5. Create output directory and ``chdir`` into it
467
- 6. Apply cross-section env override if needed
468
- 7. Export XML and call ``openmc.run()``
469
- 8. Parse statepoint → extract k_eff and tally scalars
470
- 9. Restore CWD and return ``SimulationResult``
547
+ 5. Resolve the run directory (with temp-dir fallback)
548
+ 6. Apply the cross-section override inside a module-wide lock
549
+ 7. Run via ``openmc.model.Model.run(cwd=run_dir, ...)`` (no ``chdir``)
550
+ 8. Parse the returned statepoint → extract k_eff and tally scalars
551
+ 9. Return ``SimulationResult``; failures surface as ``status="failed"``
552
+ with ``run_dir`` and error diagnostics in ``metadata``
471
553
  """
472
554
  import openmc
473
555
  from processforge.types import SimulationResult
@@ -501,54 +583,67 @@ class OpenMCProvider(AbstractProvider):
501
583
  f"{solver_cfg.batches} batches × {solver_cfg.particles} particles"
502
584
  )
503
585
 
504
- omc_materials, geometry, settings, tallies = (
505
- strategy_cls().build(openmc, solver_cfg, materials_map, helpers)
586
+ omc_materials, geometry, settings, tallies = strategy_cls().build(
587
+ openmc, solver_cfg, materials_map, helpers
506
588
  )
507
589
 
508
- # --- Working directory management ---
509
- run_dir = pathlib.Path(self._provider_output_dir)
510
- try:
511
- run_dir.mkdir(parents=True, exist_ok=True)
512
- except (PermissionError, OSError) as exc:
513
- # The configured output dir (often the mounted /data volume) may not
514
- # be writable by the container user. Fall back to a temp dir so the
515
- # simulation can still run; only the persisted XML artifacts are lost.
516
- run_dir = pathlib.Path(tempfile.mkdtemp(prefix="processforge_openmc_"))
517
- logger.warning(
518
- f"OpenMCProvider: cannot use output dir "
519
- f"'{self._provider_output_dir}' ({exc}); "
520
- f"falling back to '{run_dir}'."
521
- )
522
- original_cwd = pathlib.Path.cwd()
590
+ run_dir = self._resolve_run_dir()
523
591
 
524
- # --- Cross-section override ---
525
592
  xs_path = _resolve_omc_path(solver_cfg.cross_sections or self._cross_sections)
526
- original_xs = os.environ.get("OPENMC_CROSS_SECTIONS")
527
593
  if xs_path:
528
- os.environ["OPENMC_CROSS_SECTIONS"] = str(xs_path)
529
-
530
- try:
531
- os.chdir(run_dir)
532
-
533
- # Export model XML files
534
- omc_materials.export_to_xml()
535
- geometry.export_to_xml()
536
- settings.export_to_xml()
537
- if tallies:
538
- tallies.export_to_xml()
539
-
540
- openmc.run()
541
- logger.info(f"OpenMCProvider: '{sim_type}' completed in '{run_dir}'")
542
-
543
- scalars, metadata = self._extract_results(openmc, solver_cfg, run_dir)
594
+ self._check_cross_sections_path(xs_path)
544
595
 
545
- finally:
546
- os.chdir(original_cwd)
596
+ # Runs spawn a fresh `openmc` subprocess that reads OPENMC_CROSS_SECTIONS
597
+ # from the environment. That env var is process-global, so the whole
598
+ # export+run window is serialized (see module-level comment).
599
+ with _OPENMC_RUN_LOCK:
600
+ original_xs = os.environ.get("OPENMC_CROSS_SECTIONS")
547
601
  if xs_path:
548
- if original_xs is None:
549
- os.environ.pop("OPENMC_CROSS_SECTIONS", None)
550
- else:
551
- os.environ["OPENMC_CROSS_SECTIONS"] = original_xs
602
+ os.environ["OPENMC_CROSS_SECTIONS"] = str(xs_path)
603
+ try:
604
+ model = openmc.model.Model(
605
+ geometry=geometry,
606
+ materials=omc_materials,
607
+ settings=settings,
608
+ tallies=tallies,
609
+ )
610
+ statepoint_path = model.run(cwd=str(run_dir), export_model_xml=False)
611
+ logger.info(f"OpenMCProvider: '{sim_type}' completed in '{run_dir}'")
612
+ except Exception as exc: # noqa: BLE001
613
+ logger.exception(
614
+ f"OpenMCProvider: '{sim_type}' failed in '{run_dir}': {exc}"
615
+ )
616
+ return SimulationResult(
617
+ status="failed",
618
+ sim_type=sim_type,
619
+ scalars={},
620
+ metadata={
621
+ "run_dir": str(run_dir.resolve()),
622
+ "error": str(exc),
623
+ },
624
+ )
625
+ finally:
626
+ if xs_path:
627
+ if original_xs is None:
628
+ os.environ.pop("OPENMC_CROSS_SECTIONS", None)
629
+ else:
630
+ os.environ["OPENMC_CROSS_SECTIONS"] = original_xs
631
+
632
+ scalars, metadata = self._extract_results(
633
+ openmc, solver_cfg, statepoint_path, run_dir
634
+ )
635
+
636
+ if statepoint_path is None:
637
+ logger.warning(
638
+ f"OpenMCProvider: '{sim_type}' produced no statepoint in '{run_dir}'."
639
+ )
640
+ metadata.setdefault("error", "run produced no statepoint file")
641
+ return SimulationResult(
642
+ status="failed",
643
+ sim_type=sim_type,
644
+ scalars=scalars,
645
+ metadata=metadata,
646
+ )
552
647
 
553
648
  return SimulationResult(
554
649
  status="completed",
@@ -557,10 +652,41 @@ class OpenMCProvider(AbstractProvider):
557
652
  metadata=metadata,
558
653
  )
559
654
 
655
+ def _resolve_run_dir(self) -> pathlib.Path:
656
+ """Create the run output directory, falling back to a temp dir on failure.
657
+
658
+ The configured output dir (often the mounted /data volume) may not be
659
+ writable by the container user. Fall back to a temp dir so the
660
+ simulation can still run; only the persisted XML artifacts are lost.
661
+ """
662
+ run_dir = pathlib.Path(self._provider_output_dir)
663
+ try:
664
+ run_dir.mkdir(parents=True, exist_ok=True)
665
+ except (PermissionError, OSError) as exc:
666
+ run_dir = pathlib.Path(tempfile.mkdtemp(prefix="processforge_openmc_"))
667
+ logger.warning(
668
+ f"OpenMCProvider: cannot use output dir "
669
+ f"'{self._provider_output_dir}' ({exc}); "
670
+ f"falling back to '{run_dir}'."
671
+ )
672
+ return run_dir
673
+
674
+ @staticmethod
675
+ def _check_cross_sections_path(xs_path: str) -> None:
676
+ """Fail fast if a cross-section path does not exist."""
677
+ if pathlib.Path(xs_path).is_file():
678
+ return
679
+ raise RuntimeError(
680
+ f"OpenMC cross-sections file not found at '{xs_path}'. "
681
+ "Set a valid OPENMC_CROSS_SECTIONS / provider 'cross_sections' / "
682
+ "solver_config 'cross_sections' path."
683
+ )
684
+
560
685
  def _extract_results(
561
686
  self,
562
687
  openmc,
563
688
  solver_cfg: SolverConfig,
689
+ statepoint_path,
564
690
  run_dir: pathlib.Path,
565
691
  ) -> tuple:
566
692
  """Parse the statepoint file and return ``(scalars_dict, metadata_dict)``.
@@ -572,65 +698,66 @@ class OpenMCProvider(AbstractProvider):
572
698
  ``tally_{id}_{score}_mean_total`` — sum of all bin means
573
699
  ``tally_{id}_{score}_std_dev`` — RMS of per-bin std devs
574
700
 
575
- ``metadata["statepoint_path"]`` holds the absolute HDF5 path for
576
- downstream post-processing.
701
+ Extraction issues are collected in ``metadata["tally_warnings"]`` so
702
+ callers can observe degraded (but not failed) results. ``metadata``
703
+ always carries ``run_dir``; ``metadata["statepoint_path"]`` holds the
704
+ absolute HDF5 path for downstream post-processing.
577
705
  """
578
- import glob as _glob
579
-
580
706
  scalars: dict = {}
581
707
  metadata: dict = {"run_dir": str(run_dir.resolve())}
708
+ warnings: list = []
582
709
 
583
- sp_pattern = str(run_dir / f"statepoint.{solver_cfg.batches}.h5")
584
- sp_files = _glob.glob(sp_pattern)
585
- if not sp_files:
586
- logger.warning(
587
- f"OpenMCProvider: statepoint file not found at '{sp_pattern}'. "
588
- "Scalars will be empty."
589
- )
710
+ if statepoint_path is None:
711
+ warnings.append("run produced no statepoint file")
712
+ metadata["tally_warnings"] = warnings
590
713
  return scalars, metadata
591
714
 
592
- sp_path = sp_files[0]
593
- metadata["statepoint_path"] = str(pathlib.Path(sp_path).resolve())
594
-
595
- sp = openmc.StatePoint(sp_path)
715
+ metadata["statepoint_path"] = str(pathlib.Path(statepoint_path).resolve())
596
716
 
597
- # k_eff — only present in eigenvalue mode
598
- if sp.keff is not None:
599
- scalars["k_eff"] = float(sp.keff.n)
600
- scalars["k_eff_std_dev"] = float(sp.keff.s)
601
- logger.info(
602
- f"OpenMCProvider: k_eff = {scalars['k_eff']:.6f} "
603
- f"+/- {scalars['k_eff_std_dev']:.6f}"
604
- )
605
-
606
- # Tally scalars
607
- for tally_cfg in solver_cfg.mesh_tallies:
608
- try:
609
- tally = sp.get_tally(id=tally_cfg.tally_id)
610
- except Exception: # noqa: BLE001
611
- logger.warning(
612
- f"OpenMCProvider: tally id={tally_cfg.tally_id} not found "
613
- "in statepoint. Skipping."
717
+ sp = openmc.StatePoint(statepoint_path)
718
+ try:
719
+ # k_eff only present in eigenvalue mode
720
+ if sp.keff is not None:
721
+ scalars["k_eff"] = float(sp.keff.n)
722
+ scalars["k_eff_std_dev"] = float(sp.keff.s)
723
+ logger.info(
724
+ f"OpenMCProvider: k_eff = {scalars['k_eff']:.6f} "
725
+ f"+/- {scalars['k_eff_std_dev']:.6f}"
614
726
  )
615
- continue
727
+ elif solver_cfg.run_mode == RunMode.eigenvalue:
728
+ warnings.append("eigenvalue run produced no k_eff in statepoint")
616
729
 
617
- for score in tally_cfg.scores:
730
+ for tally_cfg in solver_cfg.mesh_tallies:
618
731
  try:
619
- df = tally.get_pandas_dataframe(scores=[score])
620
- means = df["mean"].values
621
- std_devs = df["std. dev."].values
622
- key_prefix = f"tally_{tally_cfg.tally_id}_{score}"
623
- scalars[f"{key_prefix}_mean_total"] = float(means.sum())
624
- scalars[f"{key_prefix}_std_dev"] = (
625
- float(math.sqrt((std_devs ** 2).mean())) if len(std_devs) > 0 else 0.0
626
- )
732
+ tally = sp.get_tally(id=tally_cfg.tally_id)
627
733
  except Exception as exc: # noqa: BLE001
628
- logger.warning(
629
- f"OpenMCProvider: could not extract score '{score}' "
630
- f"from tally {tally_cfg.tally_id}: {exc}"
734
+ warnings.append(
735
+ f"tally id={tally_cfg.tally_id} not found in statepoint: {exc}"
631
736
  )
737
+ continue
738
+
739
+ for score in tally_cfg.scores:
740
+ try:
741
+ df = tally.get_pandas_dataframe(scores=[score])
742
+ means = df["mean"].values
743
+ std_devs = df["std. dev."].values
744
+ key_prefix = f"tally_{tally_cfg.tally_id}_{score}"
745
+ scalars[f"{key_prefix}_mean_total"] = float(means.sum())
746
+ scalars[f"{key_prefix}_std_dev"] = (
747
+ float(math.sqrt((std_devs**2).mean()))
748
+ if len(std_devs) > 0
749
+ else 0.0
750
+ )
751
+ except Exception as exc: # noqa: BLE001
752
+ warnings.append(
753
+ f"could not extract score '{score}' from tally "
754
+ f"{tally_cfg.tally_id}: {exc}"
755
+ )
756
+ finally:
757
+ del sp
632
758
 
633
- del sp
759
+ if warnings:
760
+ metadata["tally_warnings"] = warnings
634
761
  return scalars, metadata
635
762
 
636
763