processforge 0.3.16__tar.gz → 0.3.17__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 (138) hide show
  1. {processforge-0.3.16/src/processforge.egg-info → processforge-0.3.17}/PKG-INFO +1 -1
  2. {processforge-0.3.16 → processforge-0.3.17}/flowsheets/openmc/msre_eigenvalue.json +1 -1
  3. {processforge-0.3.16 → processforge-0.3.17}/pyproject.toml +1 -1
  4. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/cli/run.py +33 -0
  5. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/container_client.py +1 -0
  6. processforge-0.3.17/src/processforge/providers/errors.py +275 -0
  7. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/festim_provider.py +3 -9
  8. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/openmc_provider.py +3 -9
  9. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/types.py +15 -0
  10. {processforge-0.3.16 → processforge-0.3.17/src/processforge.egg-info}/PKG-INFO +1 -1
  11. {processforge-0.3.16 → processforge-0.3.17}/src/processforge.egg-info/SOURCES.txt +3 -0
  12. processforge-0.3.17/tests/test_container_client.py +33 -0
  13. {processforge-0.3.16 → processforge-0.3.17}/tests/test_festim_provider.py +7 -2
  14. {processforge-0.3.16 → processforge-0.3.17}/tests/test_openmc_provider.py +19 -0
  15. processforge-0.3.17/tests/test_provider_errors.py +104 -0
  16. {processforge-0.3.16 → processforge-0.3.17}/LICENSE +0 -0
  17. {processforge-0.3.16 → processforge-0.3.17}/MANIFEST.in +0 -0
  18. {processforge-0.3.16 → processforge-0.3.17}/README.md +0 -0
  19. {processforge-0.3.16 → processforge-0.3.17}/flowsheets/closed-loop-chain.json +0 -0
  20. {processforge-0.3.16 → processforge-0.3.17}/flowsheets/coupled/msre_coupled.json +0 -0
  21. {processforge-0.3.16 → processforge-0.3.17}/flowsheets/custom/crimson/MUWS_HB.json +0 -0
  22. {processforge-0.3.16 → processforge-0.3.17}/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -0
  23. {processforge-0.3.16 → processforge-0.3.17}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
  24. {processforge-0.3.16 → processforge-0.3.17}/flowsheets/festim/tds_tungsten.json +0 -0
  25. {processforge-0.3.16 → processforge-0.3.17}/flowsheets/hydraulic-chain.json +0 -0
  26. {processforge-0.3.16 → processforge-0.3.17}/setup.cfg +0 -0
  27. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/__init__.py +0 -0
  28. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/__main__.py +0 -0
  29. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/_schema.py +0 -0
  30. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/analysis/__init__.py +0 -0
  31. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/analysis/dof.py +0 -0
  32. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/api/__init__.py +0 -0
  33. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/api/serve.py +0 -0
  34. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/cli/__init__.py +0 -0
  35. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/cli/apply.py +0 -0
  36. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/cli/common.py +0 -0
  37. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/cli/diagram.py +0 -0
  38. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/cli/display.py +0 -0
  39. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/cli/export_fmu.py +0 -0
  40. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/cli/export_modelica.py +0 -0
  41. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/cli/init.py +0 -0
  42. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/cli/plan.py +0 -0
  43. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/cli/validate.py +0 -0
  44. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/compose.py +0 -0
  45. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/coupling.py +0 -0
  46. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/__init__.py +0 -0
  47. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/backends/__init__.py +0 -0
  48. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/backends/base.py +0 -0
  49. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/backends/casadi_backend.py +0 -0
  50. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  51. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/backends/scipy_backend.py +0 -0
  52. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/flowsheet.py +0 -0
  53. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/jacobian.py +0 -0
  54. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/mixin.py +0 -0
  55. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/solver.py +0 -0
  56. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/stream_var.py +0 -0
  57. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/units/__init__.py +0 -0
  58. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/units/flash_eo.py +0 -0
  59. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/units/heater_eo.py +0 -0
  60. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/units/pipes_eo.py +0 -0
  61. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/units/pump_eo.py +0 -0
  62. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/units/strainer_eo.py +0 -0
  63. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/eo/units/valve_eo.py +0 -0
  64. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/flowsheet.py +0 -0
  65. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/fmu/__init__.py +0 -0
  66. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/fmu/_fmi_vars.py +0 -0
  67. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/fmu/builder.py +0 -0
  68. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/fmu/slave_template.py +0 -0
  69. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/lock.py +0 -0
  70. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/modelica/__init__.py +0 -0
  71. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/modelica/mo_writer.py +0 -0
  72. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/modelica/omc_runner.py +0 -0
  73. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/modelica/transpiler.py +0 -0
  74. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/modelica/unit_equations.py +0 -0
  75. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/output_collector.py +0 -0
  76. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/pcl/__init__.py +0 -0
  77. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/pcl/compiler.py +0 -0
  78. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/pcl/namespace.py +0 -0
  79. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/persistence/__init__.py +0 -0
  80. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/persistence/archive.py +0 -0
  81. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/persistence/artifact_store.py +0 -0
  82. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/provenance.py +0 -0
  83. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/__init__.py +0 -0
  84. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/base.py +0 -0
  85. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/base_jacobian_mixin.py +0 -0
  86. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/cantera_jacobian.py +0 -0
  87. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/cantera_provider.py +0 -0
  88. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/coolprop_provider.py +0 -0
  89. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/jacobian_contributor.py +0 -0
  90. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/manager.py +0 -0
  91. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/modelica_jacobian.py +0 -0
  92. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/modelica_provider.py +0 -0
  93. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/reference_state_registry.py +0 -0
  94. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/providers/registry.py +0 -0
  95. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/quantity.py +0 -0
  96. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/result.py +0 -0
  97. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/result_schema.py +0 -0
  98. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/schemas/__init__.py +0 -0
  99. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/schemas/festim/festim_model.py +0 -0
  100. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/schemas/flowsheet_schema.json +0 -0
  101. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/schemas/openmc/openmc_model.py +0 -0
  102. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/simulate.py +0 -0
  103. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/solver.py +0 -0
  104. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/state.py +0 -0
  105. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/thermo.py +0 -0
  106. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/__init__.py +0 -0
  107. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/base.py +0 -0
  108. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/cstr.py +0 -0
  109. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/flash.py +0 -0
  110. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/heater.py +0 -0
  111. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/pfr.py +0 -0
  112. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/pipes.py +0 -0
  113. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/pump.py +0 -0
  114. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/registry.py +0 -0
  115. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/solver_unit.py +0 -0
  116. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/strainer.py +0 -0
  117. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/tank.py +0 -0
  118. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/units/valve.py +0 -0
  119. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/utils/__init__.py +0 -0
  120. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/utils/flowsheet_diagram.py +0 -0
  121. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/utils/mermaid_diagram.py +0 -0
  122. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/utils/s3_upload.py +0 -0
  123. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/utils/topology.py +0 -0
  124. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/utils/unit_consistency.py +0 -0
  125. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/utils/validate_flowsheet.py +0 -0
  126. {processforge-0.3.16 → processforge-0.3.17}/src/processforge/utils/validation.py +0 -0
  127. {processforge-0.3.16 → processforge-0.3.17}/src/processforge.egg-info/dependency_links.txt +0 -0
  128. {processforge-0.3.16 → processforge-0.3.17}/src/processforge.egg-info/entry_points.txt +0 -0
  129. {processforge-0.3.16 → processforge-0.3.17}/src/processforge.egg-info/requires.txt +0 -0
  130. {processforge-0.3.16 → processforge-0.3.17}/src/processforge.egg-info/top_level.txt +0 -0
  131. {processforge-0.3.16 → processforge-0.3.17}/tests/test_cli.py +0 -0
  132. {processforge-0.3.16 → processforge-0.3.17}/tests/test_coupling.py +0 -0
  133. {processforge-0.3.16 → processforge-0.3.17}/tests/test_festim_schema.py +0 -0
  134. {processforge-0.3.16 → processforge-0.3.17}/tests/test_init.py +0 -0
  135. {processforge-0.3.16 → processforge-0.3.17}/tests/test_providers.py +0 -0
  136. {processforge-0.3.16 → processforge-0.3.17}/tests/test_s3_upload.py +0 -0
  137. {processforge-0.3.16 → processforge-0.3.17}/tests/test_state.py +0 -0
  138. {processforge-0.3.16 → processforge-0.3.17}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.3.16
3
+ Version: 0.3.17
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
@@ -26,7 +26,7 @@
26
26
  "id": 2,
27
27
  "density": 9.0,
28
28
  "density_units": "g/cm3",
29
- "temperature": 400.0,
29
+ "temperature": 300.0,
30
30
  "elements": [
31
31
  { "element": "Ni", "percent": 0.72, "percent_type": "wo" },
32
32
  { "element": "Mo", "percent": 0.16, "percent_type": "wo" },
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "processforge"
7
- version = "0.3.16"
7
+ version = "0.3.17"
8
8
  description = "A Python-based process simulation framework for chemical engineering applications."
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -87,6 +87,8 @@ def run(
87
87
  logger.info("=== Dynamic Results ===")
88
88
  results = fs.run()
89
89
 
90
+ _check_for_failed_units(fs)
91
+
90
92
  if hasattr(fs, "converged"):
91
93
  if fs.converged:
92
94
  logger.info("Dynamic simulation converged.")
@@ -106,6 +108,8 @@ def run(
106
108
  logger.info("=== Steady-State EO Results ===")
107
109
  results = fs.run()
108
110
 
111
+ _check_for_failed_units(fs)
112
+
109
113
  if hasattr(fs, "converged"):
110
114
  if fs.converged:
111
115
  logger.info("Steady-state simulation converged.")
@@ -153,3 +157,32 @@ def run(
153
157
  logger.info(f"Plots saved: {base_name}_results.png, {base_name}_timeseries.png")
154
158
  except Exception as e:
155
159
  logger.warning(f"Failed to generate plots: {type(e).__name__}: {e}")
160
+
161
+
162
+ def _check_for_failed_units(fs):
163
+ """Fail loudly if any SolverUnit run returned ``status="failed"``.
164
+
165
+ Containerized providers return HTTP 200 with a structured ``EngineOutput``
166
+ (status="failed") rather than raising, so the failure would otherwise be
167
+ swallowed and recorded as a successful run. Surface it clearly and exit
168
+ non-zero.
169
+ """
170
+ from ..types import EngineOutput
171
+
172
+ failed = {
173
+ name: out
174
+ for name, out in getattr(fs, "engine_outputs", {}).items()
175
+ if isinstance(out, EngineOutput) and out.status == "failed"
176
+ }
177
+ if not failed:
178
+ return
179
+
180
+ for name, out in failed.items():
181
+ err = out.error
182
+ category = getattr(err, "category", "unknown") if err else "unknown"
183
+ message = getattr(err, "message", "") if err else ""
184
+ logger.error(f"Unit '{name}' simulation FAILED [{category}]: {message}")
185
+ hint = getattr(err, "hint", "") if err else ""
186
+ if hint:
187
+ logger.error(f" hint: {hint}")
188
+ raise SystemExit(1)
@@ -91,6 +91,7 @@ class ContainerProviderClient(AbstractProvider):
91
91
  "out": unit_config.out,
92
92
  "sim_type": unit_config.sim_type,
93
93
  "solver_config": unit_config.solver_config,
94
+ "geometry_config": unit_config.geometry_config,
94
95
  **unit_config.extra,
95
96
  }
96
97
 
@@ -0,0 +1,275 @@
1
+ """Provider-side run-error classification.
2
+
3
+ When a simulation is executed *inside* a provider/engine (e.g. OpenMC or FESTIM
4
+ running in a Docker container) the run can fail for reasons that live entirely
5
+ in the engine — missing nuclear-data temperatures, an MPI process abort, a
6
+ geometry that excludes a source point, a tally that references an unknown
7
+ filter, … . These are distinct from *flowsheet/setup* errors (bad JSON, unknown
8
+ material, schema validation), which are caught earlier during initialization or
9
+ flowsheet validation.
10
+
11
+ This module gives every provider a single, structured way to capture such a
12
+ run-time failure:
13
+
14
+ * :class:`ProviderRunError` — a typed, engine-agnostic record of what failed,
15
+ attributed to ``source="provider"`` so Processforge (and the user) can tell a
16
+ runtime engine error apart from a flowsheet configuration error.
17
+ * :func:`classify_run_error` — turn an exception (plus any captured
18
+ stdout/stderr from the engine) into a :class:`ProviderRunError`, picking a
19
+ category and a
20
+ concrete remediation hint where one is known.
21
+ * :func:`make_failed_output` — build the ``EngineOutput(status="failed")`` that
22
+ providers return, populated with the classification.
23
+
24
+ Categories are intentionally broad so a new engine signature can be added here
25
+ without touching the providers.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ from typing import Optional
31
+
32
+ from pydantic import BaseModel
33
+
34
+ # Error categories. Strings (not an Enum) so they serialize cleanly in JSON
35
+ # through the provider HTTP API and are easy to match on in tests/docs.
36
+ NUCLEAR_DATA = "nuclear_data"
37
+ CROSS_SECTIONS = "cross_sections"
38
+ MPI_ABORT = "mpi_abort"
39
+ GEOMETRY = "geometry"
40
+ TALLY = "tally"
41
+ CONVERGENCE = "convergence"
42
+ INPUT_VALIDATION = "input_validation"
43
+ ENVIRONMENT = "environment"
44
+ UNKNOWN = "unknown"
45
+
46
+ # (category, compiled-regex, remediation hint). First match wins.
47
+ # Order matters: more specific (and more actionable) patterns come first.
48
+ _ERROR_SIGNATURES: list[tuple[str, re.Pattern[str], str]] = [
49
+ (
50
+ NUCLEAR_DATA,
51
+ re.compile(
52
+ r"nuclear data library does not contain cross sections",
53
+ re.IGNORECASE,
54
+ ),
55
+ (
56
+ "The cross-section library lacks data at the requested "
57
+ "temperature. "
58
+ "Either set the material temperature to a value present in the library "
59
+ "(e.g. 300 K), or enable openmc.Settings.temperature handling "
60
+ "(temperature_method / multipole interpolation) so intermediate "
61
+ "temperatures are treated."
62
+ ),
63
+ ),
64
+ (
65
+ CROSS_SECTIONS,
66
+ re.compile(
67
+ r"cross section[s]? (file|library|data|xml)|"
68
+ r"could not (find|read|open).*cross_section|"
69
+ r"no cross sections (available|found)|"
70
+ r"cross_sections\.xml",
71
+ re.IGNORECASE,
72
+ ),
73
+ (
74
+ "Cross-section data could not be located or parsed. Verify the "
75
+ "provider 'cross_sections' path points at a valid cross_sections.xml "
76
+ "and that the data directory is mounted into the container."
77
+ ),
78
+ ),
79
+ (
80
+ MPI_ABORT,
81
+ re.compile(r"MPI_ABORT|mpi abort|orted: \w+ rank", re.IGNORECASE),
82
+ (
83
+ "The solver process crashed (MPI_ABORT). The root cause is usually the "
84
+ "preceding engine error (e.g. nuclear-data or geometry failure); fix "
85
+ "that first. If it occurs with no preceding error, the container may "
86
+ "be out of memory or MPI may be misconfigured."
87
+ ),
88
+ ),
89
+ (
90
+ GEOMETRY,
91
+ re.compile(
92
+ r"geometry ?error|cannot find cell|no cell found|"
93
+ r"particle (got )?lost|surface .*? not (found|defined)|"
94
+ r"universe .*? not (found|defined)|geometry does not contain",
95
+ re.IGNORECASE,
96
+ ),
97
+ (
98
+ "The problem geometry is invalid or the source/particles fall outside "
99
+ "it. Check geometry_config dimensions and the source_point location, "
100
+ "and ensure every referenced material region is filled."
101
+ ),
102
+ ),
103
+ (
104
+ TALLY,
105
+ re.compile(r"tally ?error|tally .*? (not|does not) (exist|have)|invalid filter", re.IGNORECASE),
106
+ (
107
+ "A tally or its filter is invalid. Check mesh_tallies / tally scores, "
108
+ "filter IDs, and that referenced cells/meshes exist in the geometry."
109
+ ),
110
+ ),
111
+ (
112
+ CONVERGENCE,
113
+ re.compile(
114
+ r"did not converge|maximum number of (iterations|resampling)|"
115
+ r"failed to converge|stagnat",
116
+ re.IGNORECASE,
117
+ ),
118
+ (
119
+ "The solve did not converge. Increase batches/iterations or relax "
120
+ "tolerances in solver_config; verify the model is well-posed."
121
+ ),
122
+ ),
123
+ (
124
+ INPUT_VALIDATION,
125
+ re.compile(
126
+ r"valueerror|keyerror|typeerror|validation ?error|"
127
+ r"expected .*? (got|found)|missing .*? argument",
128
+ re.IGNORECASE,
129
+ ),
130
+ (
131
+ "The engine rejected the resolved configuration. This usually means a "
132
+ "value in solver_config/geometry_config is out of range or mistyped."
133
+ ),
134
+ ),
135
+ (
136
+ ENVIRONMENT,
137
+ re.compile(
138
+ r"permission denied|no such file or directory|"
139
+ r"cannot (write|create) .*? directory|"
140
+ r"shared library|lib[a-z0-9]+\.so",
141
+ re.IGNORECASE,
142
+ ),
143
+ (
144
+ "An environment/container issue (file permissions, missing library, "
145
+ "or unwritable output dir). Check the container mounts and that the "
146
+ "output directory is writable."
147
+ ),
148
+ ),
149
+ ]
150
+
151
+
152
+ class ProviderRunError(BaseModel):
153
+ """Structured record of a run-time failure that originated in a provider/engine.
154
+
155
+ Attributes:
156
+ category: One of the ``*`` constants above (``nuclear_data``, ``mpi_abort``,
157
+ …). ``unknown`` when no signature matched.
158
+ source: Always ``"provider"`` — distinguishes engine run-time failures
159
+ from flowsheet/setup validation errors.
160
+ message: A concise, human-readable one-line summary of the failure.
161
+ type: The Python exception class name (e.g. ``RuntimeError``).
162
+ detail: The full captured error text (engine stderr/stdout + traceback
163
+ tail). May be long; this is what gets logged.
164
+ hint: A concrete remediation suggestion where a category is recognised,
165
+ else a generic pointer to check the engine log.
166
+ """
167
+
168
+ category: str = UNKNOWN
169
+ source: str = "provider"
170
+ message: str = ""
171
+ type: str = "Exception"
172
+ detail: str = ""
173
+ hint: str = (
174
+ "Inspect the engine log / run directory for the root cause; this is a "
175
+ "run-time error raised by the provider, not a flowsheet configuration error."
176
+ )
177
+
178
+ @classmethod
179
+ def from_exception(
180
+ cls,
181
+ exc: BaseException,
182
+ captured: str = "",
183
+ category: Optional[str] = None,
184
+ ) -> "ProviderRunError":
185
+ """Build a :class:`ProviderRunError` from an exception (category overridable)."""
186
+ text = str(exc)
187
+ if captured:
188
+ text = f"{text}\n\n{captured}".strip()
189
+ # Prefer an explicitly supplied category (e.g. a provider already knows).
190
+ if category is None:
191
+ category = _classify_text(text)
192
+ return cls(
193
+ category=category,
194
+ message=_summarize(text) or f"{type(exc).__name__}: {text}",
195
+ type=type(exc).__name__,
196
+ detail=text,
197
+ hint=_hint_for(category),
198
+ )
199
+
200
+
201
+ def classify_run_error(
202
+ engine: str,
203
+ exc: BaseException,
204
+ captured: str = "",
205
+ ) -> ProviderRunError:
206
+ """Classify a provider run-time exception into a :class:`ProviderRunError`.
207
+
208
+ Args:
209
+ engine: Engine name (``"openmc"``, ``"festim"``, …) — recorded for context
210
+ only (it does not change classification today).
211
+ exc: The exception raised by the engine run.
212
+ captured: Optional captured engine stdout/stderr, used to improve the
213
+ classification when the raised message is terse.
214
+
215
+ Returns:
216
+ A populated :class:`ProviderRunError` (``source="provider"``).
217
+ """
218
+ err = ProviderRunError.from_exception(exc, captured=captured)
219
+ err.message = f"[{engine}] {err.message}"
220
+ return err
221
+
222
+
223
+ def make_failed_output(
224
+ engine: str,
225
+ sim_type: str,
226
+ run_dir,
227
+ err: ProviderRunError,
228
+ unit: str = "",
229
+ ) -> "EngineOutput": # type: ignore[name-defined] # imported lazily to avoid cycle
230
+ """Build the ``EngineOutput(status="failed")`` providers return on a run error."""
231
+ from processforge.types import EngineOutput
232
+
233
+ return EngineOutput(
234
+ status="failed",
235
+ engine=engine,
236
+ sim_type=sim_type,
237
+ unit=unit,
238
+ error=err,
239
+ diagnostics={
240
+ "run_dir": str(getattr(run_dir, "resolve", lambda: run_dir)()),
241
+ "error": err.detail,
242
+ "error_category": err.category,
243
+ "error_source": err.source,
244
+ },
245
+ )
246
+
247
+
248
+ def _classify_text(text: str) -> str:
249
+ for category, pattern, _hint in _ERROR_SIGNATURES:
250
+ if pattern.search(text):
251
+ return category
252
+ return UNKNOWN
253
+
254
+
255
+ def _hint_for(category: str) -> str:
256
+ for cat, _pattern, hint in _ERROR_SIGNATURES:
257
+ if cat == category:
258
+ return hint
259
+ return ProviderRunError().hint
260
+
261
+
262
+ def _summarize(text: str, limit: int = 280) -> str:
263
+ """Return the most diagnostic single line of ``text`` (engine ERROR lines win)."""
264
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
265
+ if not lines:
266
+ return ""
267
+ for ln in lines:
268
+ if ln.upper().startswith("ERROR") or "ERROR:" in ln.upper():
269
+ return ln[:limit]
270
+ # Fall back to the first line that looks like a message (not a banner/traceback).
271
+ for ln in lines:
272
+ if ln.startswith(("Traceback", "File ", "raise ", "Proc:", "NOTE:")):
273
+ continue
274
+ return ln[:limit]
275
+ return lines[0][:limit]
@@ -67,6 +67,7 @@ from processforge.types import (
67
67
  OutputField,
68
68
  OutputProvenance,
69
69
  )
70
+ from processforge.providers.errors import classify_run_error, make_failed_output
70
71
  from processforge.units import Quantity
71
72
 
72
73
  if TYPE_CHECKING:
@@ -771,15 +772,8 @@ class FestimProvider(AbstractProvider):
771
772
  logger.exception(
772
773
  f"FestimProvider: '{sim_type}' failed in '{run_dir}': {exc}"
773
774
  )
774
- return EngineOutput(
775
- status="failed",
776
- engine="festim",
777
- sim_type=sim_type,
778
- diagnostics={
779
- "run_dir": str(run_dir.resolve()),
780
- "error": str(exc),
781
- },
782
- )
775
+ err = classify_run_error("festim", exc)
776
+ return make_failed_output("festim", sim_type, run_dir, err)
783
777
  finally:
784
778
  os.chdir(prev_cwd)
785
779
 
@@ -60,6 +60,7 @@ from processforge.types import (
60
60
  OutputField,
61
61
  OutputProvenance,
62
62
  )
63
+ from processforge.providers.errors import classify_run_error, make_failed_output
63
64
  from processforge.units import Quantity
64
65
 
65
66
 
@@ -806,15 +807,8 @@ class OpenMCProvider(AbstractProvider):
806
807
  logger.exception(
807
808
  f"OpenMCProvider: '{sim_type}' failed in '{run_dir}': {exc}"
808
809
  )
809
- return EngineOutput(
810
- status="failed",
811
- engine="openmc",
812
- sim_type=sim_type,
813
- diagnostics={
814
- "run_dir": str(run_dir.resolve()),
815
- "error": str(exc),
816
- },
817
- )
810
+ err = classify_run_error("openmc", exc)
811
+ return make_failed_output("openmc", sim_type, run_dir, err)
818
812
  finally:
819
813
  if xs_path:
820
814
  if original_xs is None:
@@ -473,6 +473,10 @@ class EngineOutput(BaseModel):
473
473
  artifacts: list[OutputArtifact] = Field(default_factory=list)
474
474
  diagnostics: dict = Field(default_factory=dict)
475
475
  provenance: OutputProvenance = Field(default_factory=OutputProvenance)
476
+ # Structured classification of a run-time failure (populated only when
477
+ # status == "failed"). source is always "provider" so a runtime engine error
478
+ # is distinguishable from a flowsheet/setup validation error.
479
+ error: Optional["ProviderRunError"] = None
476
480
 
477
481
  # -- convenience accessors -------------------------------------------
478
482
  def get_field(self, name: str) -> Optional[OutputField]:
@@ -623,3 +627,14 @@ class MergedInletTimeseries(BaseModel):
623
627
 
624
628
  def __iter__(self):
625
629
  return iter(MergedInletTimeseries.model_fields)
630
+
631
+
632
+ # Imported at the end (not at module top) to avoid an import cycle: the
633
+ # providers package __init__ pulls in provider subclasses that themselves
634
+ # import this module. ``ProviderRunError`` defines no dependency on types.py,
635
+ # so importing it here — after every model above is defined — is safe.
636
+ from processforge.providers.errors import ProviderRunError # noqa: E402
637
+
638
+ # Resolve the forward reference used in ``EngineOutput.error`` now that
639
+ # ``ProviderRunError`` is available in this module's namespace.
640
+ EngineOutput.model_rebuild()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.3.16
3
+ Version: 0.3.17
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
@@ -88,6 +88,7 @@ src/processforge/providers/cantera_jacobian.py
88
88
  src/processforge/providers/cantera_provider.py
89
89
  src/processforge/providers/container_client.py
90
90
  src/processforge/providers/coolprop_provider.py
91
+ src/processforge/providers/errors.py
91
92
  src/processforge/providers/festim_provider.py
92
93
  src/processforge/providers/jacobian_contributor.py
93
94
  src/processforge/providers/manager.py
@@ -122,11 +123,13 @@ src/processforge/utils/unit_consistency.py
122
123
  src/processforge/utils/validate_flowsheet.py
123
124
  src/processforge/utils/validation.py
124
125
  tests/test_cli.py
126
+ tests/test_container_client.py
125
127
  tests/test_coupling.py
126
128
  tests/test_festim_provider.py
127
129
  tests/test_festim_schema.py
128
130
  tests/test_init.py
129
131
  tests/test_openmc_provider.py
132
+ tests/test_provider_errors.py
130
133
  tests/test_providers.py
131
134
  tests/test_s3_upload.py
132
135
  tests/test_state.py
@@ -0,0 +1,33 @@
1
+ """Tests for the CLI-side container provider HTTP client serialization."""
2
+
3
+ from processforge.providers.container_client import ContainerProviderClient
4
+ from processforge.types import UnitConfig
5
+
6
+
7
+ def test_serialize_unit_config_includes_geometry_config():
8
+ """geometry_config must survive the CLI -> container body serialization.
9
+
10
+ Regression test for the bug where geometry_config (a known UnitConfig
11
+ field) was dropped by the hardcoded serialization list, so the container
12
+ received an empty geometry_config and failed validation.
13
+ """
14
+ geometry_config = {
15
+ "type": "reactor_core",
16
+ "core_radius": 72.5,
17
+ "core_height": 160.0,
18
+ "core_material": "salt",
19
+ }
20
+ uc = UnitConfig(
21
+ type="SolverUnit",
22
+ provider="openmc",
23
+ material=3,
24
+ sim_type="eigenvalue_reactor",
25
+ solver_config={"batches": 20},
26
+ geometry_config=geometry_config,
27
+ )
28
+
29
+ body = ContainerProviderClient._serialize_unit_config(uc)
30
+
31
+ assert body["geometry_config"] == geometry_config
32
+ assert body["solver_config"] == {"batches": 20}
33
+ assert body["sim_type"] == "eigenvalue_reactor"
@@ -837,8 +837,13 @@ class TestFestimRunSimulation:
837
837
 
838
838
  result = provider.run_simulation(unit_cfg, {})
839
839
  assert result.status == "failed"
840
- assert "SNES diverged" in result.metadata["error"]
841
- assert result.metadata["run_dir"] == str((tmp_path / "festim_run").resolve())
840
+ assert "SNES diverged" in result.diagnostics["error"]
841
+ assert result.diagnostics["run_dir"] == str((tmp_path / "festim_run").resolve())
842
+ # Failure is classified and attributed to the provider, not the flowsheet.
843
+ assert result.error is not None
844
+ assert result.error.source == "provider"
845
+ assert result.diagnostics["error_source"] == "provider"
846
+ assert result.error.detail
842
847
 
843
848
  def test_run_before_init_raises(self, fake_festim):
844
849
  from processforge.providers.festim_provider import FestimProvider
@@ -435,6 +435,25 @@ def test_run_exception_returns_failed_result(tmp_path, fake_openmc):
435
435
  assert os.environ.get("OPENMC_CROSS_SECTIONS") == prior
436
436
 
437
437
 
438
+ def test_run_exception_is_classified(tmp_path, fake_openmc):
439
+ """A nuclear-data run failure must be classified with source='provider'."""
440
+ from processforge.providers.errors import NUCLEAR_DATA
441
+
442
+ provider = _init_provider(tmp_path, _default_materials())
443
+ _CTRL.raise_msg = (
444
+ "Nuclear data library does not contain cross sections for Ni58 at or near "
445
+ "400.000000 K. Available temperatures are 300 K."
446
+ )
447
+ result = provider.run_simulation(_unit_config(), {})
448
+
449
+ assert result.status == "failed"
450
+ assert result.error is not None
451
+ assert result.error.category == NUCLEAR_DATA
452
+ assert result.error.source == "provider"
453
+ assert result.diagnostics["error_category"] == NUCLEAR_DATA
454
+ assert "temperature" in result.error.hint.lower()
455
+
456
+
438
457
  def test_run_without_statepoint_returns_failed(tmp_path, fake_openmc):
439
458
  provider = _init_provider(tmp_path, _default_materials())
440
459
  result = provider.run_simulation(_unit_config(), {})
@@ -0,0 +1,104 @@
1
+ """Tests for the provider run-error classification utility."""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+
6
+ from processforge.providers.errors import (
7
+ CROSS_SECTIONS,
8
+ GEOMETRY,
9
+ MPI_ABORT,
10
+ NUCLEAR_DATA,
11
+ ProviderRunError,
12
+ UNKNOWN,
13
+ classify_run_error,
14
+ make_failed_output,
15
+ )
16
+ from processforge.types import EngineOutput
17
+
18
+
19
+ def _classify(msg: str, engine: str = "openmc", captured: str = "") -> ProviderRunError:
20
+ return classify_run_error(engine, RuntimeError(msg), captured=captured)
21
+
22
+
23
+ def test_nuclear_data_category_and_hint():
24
+ err = _classify(
25
+ "Nuclear data library does not contain cross sections for Ni58 at or near "
26
+ "400.000000 K. Available temperatures are 300 K."
27
+ )
28
+ assert err.category == NUCLEAR_DATA
29
+ assert err.source == "provider"
30
+ assert "temperature" in err.hint.lower()
31
+ assert err.type == "RuntimeError"
32
+
33
+
34
+ def test_mpi_abort_category():
35
+ err = _classify(
36
+ "RuntimeError: something bad\n"
37
+ "MPI_ABORT was invoked on rank 0 in communicator MPI_COMM_WORLD"
38
+ )
39
+ assert err.category == MPI_ABORT
40
+
41
+
42
+ def test_cross_sections_category():
43
+ err = _classify("Could not find cross_sections.xml at the configured path")
44
+ assert err.category == CROSS_SECTIONS
45
+
46
+
47
+ def test_geometry_category():
48
+ err = _classify("GeometryError: Could not find cell containing particle")
49
+ assert err.category == GEOMETRY
50
+
51
+
52
+ def test_unknown_fallback():
53
+ err = _classify("some totally opaque engine complaint with no known signature")
54
+ assert err.category == UNKNOWN
55
+ assert err.source == "provider"
56
+ assert "run-time" in err.hint
57
+
58
+
59
+ def test_message_prefixed_with_engine():
60
+ err = _classify("Nuclear data library does not contain cross sections for Ni58")
61
+ assert err.message.startswith("[openmc]")
62
+
63
+
64
+ def test_captured_text_improves_classification():
65
+ # Raised message is terse; the real signal is in the captured engine stderr.
66
+ err = _classify(
67
+ "RuntimeError",
68
+ captured="ERROR: Nuclear data library does not contain cross sections for U235",
69
+ )
70
+ assert err.category == NUCLEAR_DATA
71
+
72
+
73
+ def test_from_exception_sets_fields():
74
+ err = ProviderRunError.from_exception(ValueError("bad value"))
75
+ assert err.type == "ValueError"
76
+ assert err.category == "input_validation" or err.category # input_validation or unknown
77
+ assert err.detail
78
+
79
+
80
+ def test_make_failed_output_shape(tmp_path):
81
+ run_dir = tmp_path / "run"
82
+ run_dir.mkdir()
83
+ err = _classify("Nuclear data library does not contain cross sections for Ni58")
84
+ out = make_failed_output("openmc", "eigenvalue_reactor", run_dir, err, unit="msre")
85
+ assert isinstance(out, EngineOutput)
86
+ assert out.status == "failed"
87
+ assert out.engine == "openmc"
88
+ assert out.sim_type == "eigenvalue_reactor"
89
+ assert out.unit == "msre"
90
+ assert out.error is err
91
+ assert out.diagnostics["error_category"] == NUCLEAR_DATA
92
+ assert out.diagnostics["error_source"] == "provider"
93
+ assert out.diagnostics["run_dir"] == str(run_dir.resolve())
94
+ assert out.diagnostics["error"]
95
+
96
+
97
+ def test_engine_output_roundtrip_serialization(tmp_path):
98
+ err = _classify("Nuclear data library does not contain cross sections for Ni58")
99
+ out = make_failed_output("openmc", "eigenvalue_reactor", tmp_path, err)
100
+ dumped = out.model_dump()
101
+ restored = EngineOutput.model_validate(dumped)
102
+ assert restored.status == "failed"
103
+ assert restored.error is not None
104
+ assert restored.error.category == NUCLEAR_DATA
File without changes
File without changes
File without changes
File without changes