processforge 0.3.16__tar.gz → 0.3.19__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 (141) hide show
  1. {processforge-0.3.16 → processforge-0.3.19}/PKG-INFO +12 -6
  2. {processforge-0.3.16 → processforge-0.3.19}/README.md +12 -6
  3. {processforge-0.3.16 → processforge-0.3.19}/flowsheets/openmc/msre_eigenvalue.json +2 -2
  4. {processforge-0.3.16 → processforge-0.3.19}/pyproject.toml +1 -1
  5. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/__init__.py +1 -1
  6. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/api/serve.py +24 -0
  7. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/cli/apply.py +14 -0
  8. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/cli/run.py +48 -0
  9. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/persistence/artifact_store.py +51 -7
  10. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/container_client.py +2 -12
  11. processforge-0.3.19/src/processforge/providers/errors.py +276 -0
  12. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/festim_provider.py +4 -9
  13. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/openmc_provider.py +5 -9
  14. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/result.py +47 -2
  15. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/types.py +19 -0
  16. processforge-0.3.19/src/processforge/utils/s3_upload.py +66 -0
  17. {processforge-0.3.16 → processforge-0.3.19}/src/processforge.egg-info/PKG-INFO +12 -6
  18. {processforge-0.3.16 → processforge-0.3.19}/src/processforge.egg-info/SOURCES.txt +4 -0
  19. {processforge-0.3.16 → processforge-0.3.19}/tests/test_cli.py +10 -5
  20. processforge-0.3.19/tests/test_container_client.py +93 -0
  21. {processforge-0.3.16 → processforge-0.3.19}/tests/test_festim_provider.py +18 -7
  22. {processforge-0.3.16 → processforge-0.3.19}/tests/test_openmc_provider.py +19 -0
  23. processforge-0.3.19/tests/test_provider_errors.py +104 -0
  24. processforge-0.3.19/tests/test_result_zarr.py +73 -0
  25. processforge-0.3.19/tests/test_s3_upload.py +64 -0
  26. processforge-0.3.16/src/processforge/utils/s3_upload.py +0 -69
  27. processforge-0.3.16/tests/test_s3_upload.py +0 -81
  28. {processforge-0.3.16 → processforge-0.3.19}/LICENSE +0 -0
  29. {processforge-0.3.16 → processforge-0.3.19}/MANIFEST.in +0 -0
  30. {processforge-0.3.16 → processforge-0.3.19}/flowsheets/closed-loop-chain.json +0 -0
  31. {processforge-0.3.16 → processforge-0.3.19}/flowsheets/coupled/msre_coupled.json +0 -0
  32. {processforge-0.3.16 → processforge-0.3.19}/flowsheets/custom/crimson/MUWS_HB.json +0 -0
  33. {processforge-0.3.16 → processforge-0.3.19}/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -0
  34. {processforge-0.3.16 → processforge-0.3.19}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
  35. {processforge-0.3.16 → processforge-0.3.19}/flowsheets/festim/tds_tungsten.json +0 -0
  36. {processforge-0.3.16 → processforge-0.3.19}/flowsheets/hydraulic-chain.json +0 -0
  37. {processforge-0.3.16 → processforge-0.3.19}/setup.cfg +0 -0
  38. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/__main__.py +0 -0
  39. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/_schema.py +0 -0
  40. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/analysis/__init__.py +0 -0
  41. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/analysis/dof.py +0 -0
  42. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/api/__init__.py +0 -0
  43. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/cli/__init__.py +0 -0
  44. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/cli/common.py +0 -0
  45. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/cli/diagram.py +0 -0
  46. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/cli/display.py +0 -0
  47. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/cli/export_fmu.py +0 -0
  48. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/cli/export_modelica.py +0 -0
  49. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/cli/init.py +0 -0
  50. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/cli/plan.py +0 -0
  51. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/cli/validate.py +0 -0
  52. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/compose.py +0 -0
  53. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/coupling.py +0 -0
  54. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/__init__.py +0 -0
  55. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/backends/__init__.py +0 -0
  56. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/backends/base.py +0 -0
  57. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/backends/casadi_backend.py +0 -0
  58. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  59. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/backends/scipy_backend.py +0 -0
  60. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/flowsheet.py +0 -0
  61. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/jacobian.py +0 -0
  62. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/mixin.py +0 -0
  63. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/solver.py +0 -0
  64. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/stream_var.py +0 -0
  65. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/units/__init__.py +0 -0
  66. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/units/flash_eo.py +0 -0
  67. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/units/heater_eo.py +0 -0
  68. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/units/pipes_eo.py +0 -0
  69. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/units/pump_eo.py +0 -0
  70. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/units/strainer_eo.py +0 -0
  71. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/eo/units/valve_eo.py +0 -0
  72. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/flowsheet.py +0 -0
  73. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/fmu/__init__.py +0 -0
  74. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/fmu/_fmi_vars.py +0 -0
  75. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/fmu/builder.py +0 -0
  76. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/fmu/slave_template.py +0 -0
  77. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/lock.py +0 -0
  78. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/modelica/__init__.py +0 -0
  79. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/modelica/mo_writer.py +0 -0
  80. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/modelica/omc_runner.py +0 -0
  81. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/modelica/transpiler.py +0 -0
  82. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/modelica/unit_equations.py +0 -0
  83. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/output_collector.py +0 -0
  84. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/pcl/__init__.py +0 -0
  85. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/pcl/compiler.py +0 -0
  86. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/pcl/namespace.py +0 -0
  87. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/persistence/__init__.py +0 -0
  88. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/persistence/archive.py +0 -0
  89. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/provenance.py +0 -0
  90. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/__init__.py +0 -0
  91. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/base.py +0 -0
  92. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/base_jacobian_mixin.py +0 -0
  93. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/cantera_jacobian.py +0 -0
  94. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/cantera_provider.py +0 -0
  95. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/coolprop_provider.py +0 -0
  96. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/jacobian_contributor.py +0 -0
  97. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/manager.py +0 -0
  98. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/modelica_jacobian.py +0 -0
  99. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/modelica_provider.py +0 -0
  100. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/reference_state_registry.py +0 -0
  101. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/providers/registry.py +0 -0
  102. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/quantity.py +0 -0
  103. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/result_schema.py +0 -0
  104. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/schemas/__init__.py +0 -0
  105. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/schemas/festim/festim_model.py +0 -0
  106. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/schemas/flowsheet_schema.json +0 -0
  107. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/schemas/openmc/openmc_model.py +0 -0
  108. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/simulate.py +0 -0
  109. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/solver.py +0 -0
  110. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/state.py +0 -0
  111. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/thermo.py +0 -0
  112. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/__init__.py +0 -0
  113. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/base.py +0 -0
  114. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/cstr.py +0 -0
  115. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/flash.py +0 -0
  116. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/heater.py +0 -0
  117. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/pfr.py +0 -0
  118. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/pipes.py +0 -0
  119. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/pump.py +0 -0
  120. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/registry.py +0 -0
  121. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/solver_unit.py +0 -0
  122. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/strainer.py +0 -0
  123. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/tank.py +0 -0
  124. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/units/valve.py +0 -0
  125. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/utils/__init__.py +0 -0
  126. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/utils/flowsheet_diagram.py +0 -0
  127. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/utils/mermaid_diagram.py +0 -0
  128. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/utils/topology.py +0 -0
  129. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/utils/unit_consistency.py +0 -0
  130. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/utils/validate_flowsheet.py +0 -0
  131. {processforge-0.3.16 → processforge-0.3.19}/src/processforge/utils/validation.py +0 -0
  132. {processforge-0.3.16 → processforge-0.3.19}/src/processforge.egg-info/dependency_links.txt +0 -0
  133. {processforge-0.3.16 → processforge-0.3.19}/src/processforge.egg-info/entry_points.txt +0 -0
  134. {processforge-0.3.16 → processforge-0.3.19}/src/processforge.egg-info/requires.txt +0 -0
  135. {processforge-0.3.16 → processforge-0.3.19}/src/processforge.egg-info/top_level.txt +0 -0
  136. {processforge-0.3.16 → processforge-0.3.19}/tests/test_coupling.py +0 -0
  137. {processforge-0.3.16 → processforge-0.3.19}/tests/test_festim_schema.py +0 -0
  138. {processforge-0.3.16 → processforge-0.3.19}/tests/test_init.py +0 -0
  139. {processforge-0.3.16 → processforge-0.3.19}/tests/test_providers.py +0 -0
  140. {processforge-0.3.16 → processforge-0.3.19}/tests/test_state.py +0 -0
  141. {processforge-0.3.16 → processforge-0.3.19}/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.19
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
@@ -114,12 +114,18 @@ uv tool install "processforge[modelica]"
114
114
 
115
115
  4. **Look at the output**
116
116
 
117
- `pf apply` writes results under `outputs/`:
118
- - `*_results.zarr` — simulation results store (per-variable arrays, composition flattened)
119
- - `*_results.zarr.schema.json` — schema file describing streams, variables, dtypes, units, shapes, and run provenance
120
- - `*.pfstate/` — versioned snapshot store with a `latest` pointer
117
+ `pf apply` (and `pf run`) write a single unified archive under `outputs/`:
121
118
 
122
- For flowsheets with Tank units (dynamic), use `pf run` instead of `pf apply` to solve with the SM solver.
119
+ - `outputs/<base>.pfarchive/` the unified store for a flowsheet's solved state and outputs:
120
+ - `snapshots/` — Zarr store of converged state vectors (one group per successful `pf apply`), each with `x`/`x_delta` arrays and config/var-name/metadata attributes, plus a `latest` pointer (powers warm-start, drift detection, and homotopy).
121
+ - `runs/<run_id>.json` — the run manifest: every stream and unit engine output (values, units, dtypes, shapes) and run provenance (backend, version, flowsheet hash).
122
+ - `outputs/streams/<name>.json` — per-stream timeseries from the solve.
123
+ - `artifacts.json` — content-addressed registry of all output artifacts (local + remote URIs).
124
+ - `index.json` — `field_name → occurrences` index for fast cross-run lookups.
125
+ - `latest_run` — plain-text pointer to the most recent run.
126
+ - `outputs/<base>_divergence.json` — written only when both direct and homotopy solves fail on `pf apply`, capturing drifted params, solver stats, and top residual violators.
127
+
128
+ For dynamic flowsheets with Tank units, use `pf run` (not `pf apply`, which is steady-state EO only) to solve with the dynamic engine.
123
129
 
124
130
  ## Python API
125
131
 
@@ -52,12 +52,18 @@ uv tool install "processforge[modelica]"
52
52
 
53
53
  4. **Look at the output**
54
54
 
55
- `pf apply` writes results under `outputs/`:
56
- - `*_results.zarr` — simulation results store (per-variable arrays, composition flattened)
57
- - `*_results.zarr.schema.json` — schema file describing streams, variables, dtypes, units, shapes, and run provenance
58
- - `*.pfstate/` — versioned snapshot store with a `latest` pointer
59
-
60
- For flowsheets with Tank units (dynamic), use `pf run` instead of `pf apply` to solve with the SM solver.
55
+ `pf apply` (and `pf run`) write a single unified archive under `outputs/`:
56
+
57
+ - `outputs/<base>.pfarchive/`the unified store for a flowsheet's solved state and outputs:
58
+ - `snapshots/` — Zarr store of converged state vectors (one group per successful `pf apply`), each with `x`/`x_delta` arrays and config/var-name/metadata attributes, plus a `latest` pointer (powers warm-start, drift detection, and homotopy).
59
+ - `runs/<run_id>.json` — the run manifest: every stream and unit engine output (values, units, dtypes, shapes) and run provenance (backend, version, flowsheet hash).
60
+ - `outputs/streams/<name>.json` per-stream timeseries from the solve.
61
+ - `artifacts.json` — content-addressed registry of all output artifacts (local + remote URIs).
62
+ - `index.json` — `field_name → occurrences` index for fast cross-run lookups.
63
+ - `latest_run` — plain-text pointer to the most recent run.
64
+ - `outputs/<base>_divergence.json` — written only when both direct and homotopy solves fail on `pf apply`, capturing drifted params, solver stats, and top residual violators.
65
+
66
+ For dynamic flowsheets with Tank units, use `pf run` (not `pf apply`, which is steady-state EO only) to solve with the dynamic engine.
61
67
 
62
68
  ## Python API
63
69
 
@@ -7,7 +7,7 @@
7
7
  "providers": {
8
8
  "openmc": {
9
9
  "type": "openmc",
10
- "url": "http://localhost:9001",
10
+ "url": "https://processforge-openmc-production.up.railway.app",
11
11
  "output_dir": "openmc/msre_run",
12
12
  "cross_sections": "${OPENMC_DATA_ROOT}/cross_sections/cross_sections.xml"
13
13
  }
@@ -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.19"
8
8
  description = "A Python-based process simulation framework for chemical engineering applications."
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -29,7 +29,7 @@ from .units.flash import Flash
29
29
  from .units.heater import Heater
30
30
  from .eo import EOFlowsheet, EOSolver
31
31
 
32
- __version__ = "0.3.16"
32
+ __version__ = "0.3.19"
33
33
 
34
34
  __all__ = [
35
35
  "Flowsheet",
@@ -99,7 +99,31 @@ def run(body: dict):
99
99
  provider.teardown()
100
100
 
101
101
 
102
+ def _log_startup() -> None:
103
+ """Report where runs are kept and the S3 upload status at server start."""
104
+ root = os.environ.get("PROCESSFORGE_OUTPUT_DIR", "outputs")
105
+ logger.info(
106
+ f"Provider run outputs (scratch) are kept locally in: {root} "
107
+ f"(per-provider subdirs: outputs/openmc, outputs/festim)."
108
+ )
109
+ if os.environ.get("S3_BUCKET"):
110
+ bucket = os.environ["S3_BUCKET"]
111
+ logger.info(f"Remote artifact upload enabled → S3 bucket '{bucket}'.")
112
+ from processforge.utils.s3_upload import validate_s3
113
+
114
+ validate_s3() # fail fast on misconfigured credentials
115
+ else:
116
+ logger.warning(
117
+ "S3_BUCKET not set — remote upload disabled; runs are ephemeral "
118
+ "on this container."
119
+ )
120
+
121
+
122
+ _log_startup()
123
+
124
+
102
125
  if __name__ == "__main__":
103
126
  import uvicorn
104
127
 
105
128
  uvicorn.run(app, host="0.0.0.0", port=DEFAULT_PORT)
129
+
@@ -27,6 +27,20 @@ def _persist_run(archive, fs, results, run_info, config, base_name, snapshot_id)
27
27
  manifest.snapshot_id = snapshot_id
28
28
  stream_results = {k: v for k, v in results.items() if not hasattr(v, "fields")}
29
29
  archive.save_run(manifest, stream_results=stream_results)
30
+
31
+ # Always persist a Zarr copy of the standardized outputs (fields + artifacts)
32
+ # inside the archive, mirroring `pf run`.
33
+ try:
34
+ from ..result import save_results_zarr
35
+
36
+ save_results_zarr(
37
+ results,
38
+ os.path.join(archive.path, "results.zarr"),
39
+ run_info,
40
+ )
41
+ except Exception as e:
42
+ logger.warning(f"Failed to write results.zarr: {type(e).__name__}: {e}")
43
+
30
44
  logger.info(f"Run saved : {run_id}")
31
45
  return run_id
32
46
 
@@ -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.")
@@ -130,6 +134,21 @@ def run(
130
134
  }
131
135
  archive.save_run(manifest, stream_results=stream_results)
132
136
 
137
+ # Always persist a Zarr copy of the standardized outputs (fields + artifacts)
138
+ # inside the archive. Works for every provider (OpenMC, CoolProp, FESTIM,
139
+ # Cantera) and for both local and remote docker runs — remote artifacts
140
+ # reference their S3 remote_uris.
141
+ try:
142
+ from ..result import save_results_zarr
143
+
144
+ save_results_zarr(
145
+ results,
146
+ os.path.join(archive.path, "results.zarr"),
147
+ run_info,
148
+ )
149
+ except Exception as e:
150
+ logger.warning(f"Failed to write results.zarr: {type(e).__name__}: {e}")
151
+
133
152
  logger.info(f"Backend : {display_backend(config, getattr(fs, 'backend', 'dynamic'))}")
134
153
  logger.info(f"Run saved : {os.path.join(archive_path, 'runs', run_id + '.json')}")
135
154
 
@@ -153,3 +172,32 @@ def run(
153
172
  logger.info(f"Plots saved: {base_name}_results.png, {base_name}_timeseries.png")
154
173
  except Exception as e:
155
174
  logger.warning(f"Failed to generate plots: {type(e).__name__}: {e}")
175
+
176
+
177
+ def _check_for_failed_units(fs):
178
+ """Fail loudly if any SolverUnit run returned ``status="failed"``.
179
+
180
+ Containerized providers return HTTP 200 with a structured ``EngineOutput``
181
+ (status="failed") rather than raising, so the failure would otherwise be
182
+ swallowed and recorded as a successful run. Surface it clearly and exit
183
+ non-zero.
184
+ """
185
+ from ..types import EngineOutput
186
+
187
+ failed = {
188
+ name: out
189
+ for name, out in getattr(fs, "engine_outputs", {}).items()
190
+ if isinstance(out, EngineOutput) and out.status == "failed"
191
+ }
192
+ if not failed:
193
+ return
194
+
195
+ for name, out in failed.items():
196
+ err = out.error
197
+ category = getattr(err, "category", "unknown") if err else "unknown"
198
+ message = getattr(err, "message", "") if err else ""
199
+ logger.error(f"Unit '{name}' simulation FAILED [{category}]: {message}")
200
+ hint = getattr(err, "hint", "") if err else ""
201
+ if hint:
202
+ logger.error(f" hint: {hint}")
203
+ raise SystemExit(1)
@@ -1,7 +1,6 @@
1
1
  """Content-addressed artifact storage for simulation outputs.
2
2
 
3
- Replaces the old ``upload_directory_to_s3`` whole-directory dump. Providers
4
- declare their output files as :class:`~processforge.types.OutputArtifact`
3
+ Providers declare their output files as :class:`~processforge.types.OutputArtifact`
5
4
  records (with ``local_path``); the :class:`ArtifactStore` computes a content
6
5
  hash, uploads each to object storage under a deterministic, reproducible key,
7
6
  and fills in ``remote_uris``. The CLI and the container server both use this
@@ -120,13 +119,58 @@ class ArtifactStore:
120
119
  # Container-side convenience: persist all artifacts in engine outputs
121
120
  # ------------------------------------------------------------------
122
121
  def persist_outputs(self, outputs, *, run_id: str = "", flowsheet_hash: str = "", unit: str = ""):
123
- """Upload every artifact referenced by *outputs*.
122
+ """Upload every artifact referenced by *outputs* to object storage.
124
123
 
125
- Mutates the artifacts in place, filling ``remote_uris``. ``outputs``
126
- may be a single object or an iterable of them.
124
+ Uploads the explicitly-declared artifacts, then sweeps each output's
125
+ ``run_dir`` for any remaining files so *all* model outputs (not just the
126
+ ones a provider happened to declare) reach S3. Newly swept files are
127
+ appended to ``out.artifacts`` with ``remote_uris`` filled. Mutates the
128
+ artifacts in place. ``outputs`` may be a single object or an iterable.
127
129
  """
130
+ if not self.bucket:
131
+ logger.debug(
132
+ "Remote artifact upload disabled (S3_BUCKET not set); provider "
133
+ "run outputs remain in the container's local scratch only."
134
+ )
135
+ return
136
+
128
137
  items = outputs if isinstance(outputs, (list, tuple)) else [outputs]
138
+ uploaded = 0
139
+ local_only = 0
129
140
  for out in items:
141
+ seen_paths: set[str] = set()
130
142
  for art in getattr(out, "artifacts", []):
131
- if art.local_path and (self.bucket or os.environ.get("S3_BUCKET")):
132
- self.upload(art, run_id=run_id, flowsheet_hash=flowsheet_hash, unit=unit)
143
+ if not art.local_path:
144
+ continue
145
+ seen_paths.add(os.path.abspath(art.local_path))
146
+ self.upload(art, run_id=run_id, flowsheet_hash=flowsheet_hash, unit=unit)
147
+ if art.remote_uris:
148
+ uploaded += 1
149
+ else:
150
+ local_only += 1
151
+
152
+ run_dir = getattr(out, "run_dir", "") or ""
153
+ if run_dir and os.path.isdir(run_dir):
154
+ for root, _dirs, files in os.walk(run_dir):
155
+ for name in files:
156
+ path = os.path.abspath(os.path.join(root, name))
157
+ if path in seen_paths:
158
+ continue
159
+ seen_paths.add(path)
160
+ art = OutputArtifact(
161
+ name=name,
162
+ kind=_artifact_kind(name),
163
+ local_path=path,
164
+ source="local",
165
+ )
166
+ self.upload(art, run_id=run_id, flowsheet_hash=flowsheet_hash, unit=unit)
167
+ if art.remote_uris:
168
+ out.artifacts.append(art)
169
+ uploaded += 1
170
+ else:
171
+ local_only += 1
172
+
173
+ logger.info(
174
+ f"Artifact persistence: {uploaded} uploaded to S3, {local_only} local-only."
175
+ )
176
+
@@ -10,14 +10,13 @@ as a thin HTTP wrapper around that server.
10
10
  Request/response contract (matches ``processforge.api.serve``)
11
11
  ----------------------------------------------------------------
12
12
  * ``POST /run`` with a JSON body
13
- ``{"unit_config", "materials", "inlet", "output_dir", "provider_config"}``.
13
+ ``{"unit_config", "materials", "inlet", "provider_config"}``.
14
14
  * JSON response ``{"status", "sim_type", <scalars…>, "metadata"}`` deserialised
15
15
  into a :class:`~processforge.types.SimulationResult`.
16
16
  """
17
17
  from __future__ import annotations
18
18
 
19
19
  import json
20
- import os
21
20
  import urllib.error
22
21
  import urllib.request
23
22
  from typing import TYPE_CHECKING, Optional
@@ -54,7 +53,6 @@ class ContainerProviderClient(AbstractProvider):
54
53
  def __init__(self, provider_type: str):
55
54
  self._ptype = provider_type
56
55
  self._url: Optional[str] = None
57
- self._provider_output_dir: str = "outputs"
58
56
  self._provider_config: Optional["ProviderConfig"] = None
59
57
  self._materials: dict = {}
60
58
  self._initialized: bool = False
@@ -91,6 +89,7 @@ class ContainerProviderClient(AbstractProvider):
91
89
  "out": unit_config.out,
92
90
  "sim_type": unit_config.sim_type,
93
91
  "solver_config": unit_config.solver_config,
92
+ "geometry_config": unit_config.geometry_config,
94
93
  **unit_config.extra,
95
94
  }
96
95
 
@@ -122,14 +121,6 @@ class ContainerProviderClient(AbstractProvider):
122
121
  """Store config, resolve URL, verify the Docker service is reachable."""
123
122
  self._url = self._resolve_url(provider_config)
124
123
 
125
- out_dir = os.path.expandvars(
126
- getattr(provider_config, "output_dir", "outputs") or "outputs"
127
- )
128
- if not os.path.isabs(out_dir):
129
- root = os.environ.get("PROCESSFORGE_OUTPUT_DIR", "outputs")
130
- out_dir = os.path.join(root, out_dir)
131
- self._provider_output_dir = out_dir
132
-
133
124
  # Material registry — serialised into /run and used by validate_material().
134
125
  self._materials = dict(flowsheet_config.materials)
135
126
  self._provider_config = provider_config
@@ -190,7 +181,6 @@ class ContainerProviderClient(AbstractProvider):
190
181
  "unit_config": self._serialize_unit_config(unit_config),
191
182
  "materials": self._serialize_materials(self._materials),
192
183
  "inlet": inlet,
193
- "output_dir": self._provider_output_dir,
194
184
  "provider_config": provider_config_dump,
195
185
  "run_id": run_id,
196
186
  "flowsheet_hash": flowsheet_hash,
@@ -0,0 +1,276 @@
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
+ run_dir=str(getattr(run_dir, "resolve", lambda: run_dir)()),
240
+ diagnostics={
241
+ "run_dir": str(getattr(run_dir, "resolve", lambda: run_dir)()),
242
+ "error": err.detail,
243
+ "error_category": err.category,
244
+ "error_source": err.source,
245
+ },
246
+ )
247
+
248
+
249
+ def _classify_text(text: str) -> str:
250
+ for category, pattern, _hint in _ERROR_SIGNATURES:
251
+ if pattern.search(text):
252
+ return category
253
+ return UNKNOWN
254
+
255
+
256
+ def _hint_for(category: str) -> str:
257
+ for cat, _pattern, hint in _ERROR_SIGNATURES:
258
+ if cat == category:
259
+ return hint
260
+ return ProviderRunError().hint
261
+
262
+
263
+ def _summarize(text: str, limit: int = 280) -> str:
264
+ """Return the most diagnostic single line of ``text`` (engine ERROR lines win)."""
265
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
266
+ if not lines:
267
+ return ""
268
+ for ln in lines:
269
+ if ln.upper().startswith("ERROR") or "ERROR:" in ln.upper():
270
+ return ln[:limit]
271
+ # Fall back to the first line that looks like a message (not a banner/traceback).
272
+ for ln in lines:
273
+ if ln.startswith(("Traceback", "File ", "raise ", "Proc:", "NOTE:")):
274
+ continue
275
+ return ln[:limit]
276
+ 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
 
@@ -794,6 +788,7 @@ class FestimProvider(AbstractProvider):
794
788
  artifacts=artifacts,
795
789
  diagnostics=diagnostics,
796
790
  provenance=OutputProvenance(),
791
+ run_dir=str(run_dir),
797
792
  )
798
793
 
799
794
  def _resolve_run_dir(self) -> pathlib.Path: