processforge 0.3.25__tar.gz → 0.3.26__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 (144) hide show
  1. {processforge-0.3.25/src/processforge.egg-info → processforge-0.3.26}/PKG-INFO +1 -1
  2. {processforge-0.3.25 → processforge-0.3.26}/pyproject.toml +1 -1
  3. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/__init__.py +1 -1
  4. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/cli/apply.py +21 -50
  5. processforge-0.3.26/src/processforge/cli/persist.py +99 -0
  6. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/cli/run.py +6 -43
  7. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/flowsheet.py +46 -12
  8. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/solver.py +104 -65
  9. {processforge-0.3.25 → processforge-0.3.26/src/processforge.egg-info}/PKG-INFO +1 -1
  10. {processforge-0.3.25 → processforge-0.3.26}/src/processforge.egg-info/SOURCES.txt +2 -0
  11. processforge-0.3.26/tests/test_persist.py +73 -0
  12. {processforge-0.3.25 → processforge-0.3.26}/LICENSE +0 -0
  13. {processforge-0.3.25 → processforge-0.3.26}/MANIFEST.in +0 -0
  14. {processforge-0.3.25 → processforge-0.3.26}/README.md +0 -0
  15. {processforge-0.3.25 → processforge-0.3.26}/flowsheets/closed-loop-chain.json +0 -0
  16. {processforge-0.3.25 → processforge-0.3.26}/flowsheets/coupled/msre_coupled.json +0 -0
  17. {processforge-0.3.25 → processforge-0.3.26}/flowsheets/custom/crimson/MUWS_HB.json +0 -0
  18. {processforge-0.3.25 → processforge-0.3.26}/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -0
  19. {processforge-0.3.25 → processforge-0.3.26}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
  20. {processforge-0.3.25 → processforge-0.3.26}/flowsheets/festim/tds_tungsten.json +0 -0
  21. {processforge-0.3.25 → processforge-0.3.26}/flowsheets/hydraulic-chain.json +0 -0
  22. {processforge-0.3.25 → processforge-0.3.26}/flowsheets/openmc/msre_eigenvalue.json +0 -0
  23. {processforge-0.3.25 → processforge-0.3.26}/setup.cfg +0 -0
  24. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/__main__.py +0 -0
  25. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/_schema.py +0 -0
  26. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/analysis/__init__.py +0 -0
  27. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/analysis/dof.py +0 -0
  28. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/api/__init__.py +0 -0
  29. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/api/serve.py +0 -0
  30. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/cli/__init__.py +0 -0
  31. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/cli/common.py +0 -0
  32. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/cli/diagram.py +0 -0
  33. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/cli/display.py +0 -0
  34. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/cli/export_fmu.py +0 -0
  35. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/cli/export_modelica.py +0 -0
  36. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/cli/init.py +0 -0
  37. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/cli/plan.py +0 -0
  38. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/cli/runs.py +0 -0
  39. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/cli/validate.py +0 -0
  40. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/compose.py +0 -0
  41. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/coupling.py +0 -0
  42. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/__init__.py +0 -0
  43. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/backends/__init__.py +0 -0
  44. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/backends/base.py +0 -0
  45. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/backends/casadi_backend.py +0 -0
  46. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  47. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/backends/scipy_backend.py +0 -0
  48. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/jacobian.py +0 -0
  49. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/mixin.py +0 -0
  50. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/stream_var.py +0 -0
  51. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/units/__init__.py +0 -0
  52. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/units/flash_eo.py +0 -0
  53. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/units/heater_eo.py +0 -0
  54. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/units/pipes_eo.py +0 -0
  55. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/units/pump_eo.py +0 -0
  56. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/units/strainer_eo.py +0 -0
  57. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/eo/units/valve_eo.py +0 -0
  58. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/flowsheet.py +0 -0
  59. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/fmu/__init__.py +0 -0
  60. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/fmu/_fmi_vars.py +0 -0
  61. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/fmu/builder.py +0 -0
  62. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/fmu/slave_template.py +0 -0
  63. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/lock.py +0 -0
  64. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/modelica/__init__.py +0 -0
  65. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/modelica/mo_writer.py +0 -0
  66. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/modelica/omc_runner.py +0 -0
  67. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/modelica/transpiler.py +0 -0
  68. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/modelica/unit_equations.py +0 -0
  69. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/output_collector.py +0 -0
  70. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/pcl/__init__.py +0 -0
  71. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/pcl/compiler.py +0 -0
  72. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/pcl/namespace.py +0 -0
  73. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/persistence/__init__.py +0 -0
  74. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/persistence/archive.py +0 -0
  75. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/persistence/artifact_store.py +0 -0
  76. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/provenance.py +0 -0
  77. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/__init__.py +0 -0
  78. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/base.py +0 -0
  79. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/base_jacobian_mixin.py +0 -0
  80. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/cantera_jacobian.py +0 -0
  81. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/cantera_provider.py +0 -0
  82. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/container_client.py +0 -0
  83. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/coolprop_provider.py +0 -0
  84. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/errors.py +0 -0
  85. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/festim_provider.py +0 -0
  86. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/jacobian_contributor.py +0 -0
  87. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/manager.py +0 -0
  88. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/modelica_jacobian.py +0 -0
  89. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/modelica_provider.py +0 -0
  90. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/openmc_provider.py +0 -0
  91. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/reference_state_registry.py +0 -0
  92. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/providers/registry.py +0 -0
  93. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/quantity.py +0 -0
  94. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/result.py +0 -0
  95. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/result_schema.py +0 -0
  96. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/schemas/__init__.py +0 -0
  97. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/schemas/festim/festim_model.py +0 -0
  98. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/schemas/flowsheet_schema.json +0 -0
  99. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/schemas/openmc/openmc_model.py +0 -0
  100. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/simulate.py +0 -0
  101. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/solver.py +0 -0
  102. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/state.py +0 -0
  103. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/thermo.py +0 -0
  104. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/types.py +0 -0
  105. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/__init__.py +0 -0
  106. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/base.py +0 -0
  107. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/cstr.py +0 -0
  108. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/flash.py +0 -0
  109. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/heater.py +0 -0
  110. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/pfr.py +0 -0
  111. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/pipes.py +0 -0
  112. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/pump.py +0 -0
  113. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/registry.py +0 -0
  114. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/solver_unit.py +0 -0
  115. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/strainer.py +0 -0
  116. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/tank.py +0 -0
  117. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/units/valve.py +0 -0
  118. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/utils/__init__.py +0 -0
  119. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/utils/flowsheet_diagram.py +0 -0
  120. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/utils/mermaid_diagram.py +0 -0
  121. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/utils/s3_upload.py +0 -0
  122. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/utils/topology.py +0 -0
  123. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/utils/unit_consistency.py +0 -0
  124. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/utils/validate_flowsheet.py +0 -0
  125. {processforge-0.3.25 → processforge-0.3.26}/src/processforge/utils/validation.py +0 -0
  126. {processforge-0.3.25 → processforge-0.3.26}/src/processforge.egg-info/dependency_links.txt +0 -0
  127. {processforge-0.3.25 → processforge-0.3.26}/src/processforge.egg-info/entry_points.txt +0 -0
  128. {processforge-0.3.25 → processforge-0.3.26}/src/processforge.egg-info/requires.txt +0 -0
  129. {processforge-0.3.25 → processforge-0.3.26}/src/processforge.egg-info/top_level.txt +0 -0
  130. {processforge-0.3.25 → processforge-0.3.26}/tests/test_cli.py +0 -0
  131. {processforge-0.3.25 → processforge-0.3.26}/tests/test_container_client.py +0 -0
  132. {processforge-0.3.25 → processforge-0.3.26}/tests/test_coupling.py +0 -0
  133. {processforge-0.3.25 → processforge-0.3.26}/tests/test_display.py +0 -0
  134. {processforge-0.3.25 → processforge-0.3.26}/tests/test_festim_provider.py +0 -0
  135. {processforge-0.3.25 → processforge-0.3.26}/tests/test_festim_schema.py +0 -0
  136. {processforge-0.3.25 → processforge-0.3.26}/tests/test_init.py +0 -0
  137. {processforge-0.3.25 → processforge-0.3.26}/tests/test_openmc_provider.py +0 -0
  138. {processforge-0.3.25 → processforge-0.3.26}/tests/test_provider_errors.py +0 -0
  139. {processforge-0.3.25 → processforge-0.3.26}/tests/test_providers.py +0 -0
  140. {processforge-0.3.25 → processforge-0.3.26}/tests/test_result_zarr.py +0 -0
  141. {processforge-0.3.25 → processforge-0.3.26}/tests/test_runs_command.py +0 -0
  142. {processforge-0.3.25 → processforge-0.3.26}/tests/test_s3_upload.py +0 -0
  143. {processforge-0.3.25 → processforge-0.3.26}/tests/test_state.py +0 -0
  144. {processforge-0.3.25 → processforge-0.3.26}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.3.25
3
+ Version: 0.3.26
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.25"
7
+ version = "0.3.26"
8
8
  description = "A Python-based process simulation framework for chemical engineering applications."
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -30,7 +30,7 @@ from .units.flash import Flash
30
30
  from .units.heater import Heater
31
31
  from .eo import EOFlowsheet, EOSolver
32
32
 
33
- __version__ = "0.3.25"
33
+ __version__ = "0.3.26"
34
34
 
35
35
  __all__ = [
36
36
  "Flowsheet",
@@ -6,46 +6,10 @@ import os
6
6
  import time
7
7
  from typing import Literal
8
8
 
9
- import hashlib
10
- import json
11
- import os
12
9
  import typer
13
- from datetime import datetime, timezone
14
10
  from loguru import logger
15
11
 
16
-
17
- def _persist_run(archive, fs, results, run_info, config, base_name, snapshot_id):
18
- """Build a standardized RunManifest and persist it to the archive."""
19
- run_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "_" + os.urandom(3).hex()
20
- flowsheet_hash = hashlib.sha256(
21
- json.dumps(config, sort_keys=True, default=str).encode("utf-8")
22
- ).hexdigest()[:16]
23
- manifest = fs.collect_outputs(
24
- run_id, config.get("simulation", {}).get("mode", "steady"),
25
- base_name, provenance=run_info.model_dump(),
26
- )
27
- manifest.snapshot_id = snapshot_id
28
- stream_results = {k: v for k, v in results.items() if not hasattr(v, "fields")}
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 relink_latest_results, save_results_zarr
35
-
36
- run_results_dir = os.path.join(archive.path, "results", run_id)
37
- save_results_zarr(
38
- results,
39
- os.path.join(run_results_dir, "results.zarr"),
40
- run_info,
41
- )
42
- relink_latest_results(archive.path, run_id)
43
- except Exception as e:
44
- logger.warning(f"Failed to write results.zarr: {type(e).__name__}: {e}")
45
-
46
- logger.info(f"Run saved : {run_id}")
47
- return run_id
48
-
12
+ from .persist import make_run_id, persist_run
49
13
  from ..eo import EOFlowsheet
50
14
  from ..provenance import build_run_info
51
15
  from .common import (
@@ -158,14 +122,15 @@ def apply(
158
122
  label="converged state",
159
123
  )
160
124
  run_info = build_run_info(config, x0=fs.x0, var_names=fs.var_names)
161
- saved_run_id = _persist_run(sm, fs, results, run_info, config, base_name, snapshot_id)
125
+ run_id = make_run_id()
126
+ persist_run(sm, fs, run_id, results, run_info, config, base_name, snapshot_id)
162
127
  logger.info("=== Apply Summary ===")
163
128
  logger.info(" Status : CONVERGED")
164
129
  logger.info(f" Final ||F|| : {fs.solver_stats.get('final_norm', '?'):.3e}")
165
130
  logger.info(f" Iterations : {fs.solver_stats.get('iterations', '?')}")
166
131
  logger.info(f" Backend : {display_backend(config, fs.backend)}")
167
132
  logger.info(f" Snapshot ID : {snapshot_id}")
168
- logger.info(f" Run ID : {saved_run_id}")
133
+ logger.info(f" Run ID : {run_id}")
169
134
  logger.info(f" Elapsed (s) : {elapsed:.2f}")
170
135
  return
171
136
 
@@ -180,25 +145,31 @@ def apply(
180
145
  tmp_fs = _EO(config, backend=backend)
181
146
  manager = tmp_fs._build()
182
147
  try:
183
- x_hom, hom_converged, hom_stats = solve_with_homotopy(
184
- tmp_fs, manager, solver, state, drifted
185
- )
148
+ homotopy_result = solve_with_homotopy(tmp_fs, manager, solver, state, drifted)
149
+ if homotopy_result.converged:
150
+ # Assemble the real (converged) outputs from the homotopy
151
+ # solution while the providers are still alive. The `results`
152
+ # from the earlier failed direct solve must NOT be persisted.
153
+ tmp_fs.assemble_from_solution(
154
+ manager, homotopy_result.x_solution, True, homotopy_result.stats
155
+ )
186
156
  finally:
187
157
  teardown_providers(tmp_fs._provider_map)
188
158
 
189
- if hom_converged:
159
+ if homotopy_result.converged:
190
160
  logger.info(
191
- f"Homotopy converged: ||F||={hom_stats.get('final_norm', '?'):.3e}, "
192
- f"iterations={hom_stats.get('iterations', '?')}"
161
+ f"Homotopy converged: ||F||={homotopy_result.stats.get('final_norm', '?'):.3e}, "
162
+ f"iterations={homotopy_result.stats.get('iterations', '?')}"
193
163
  )
194
- save_snapshot(
195
- sm, config, x_hom, fs.var_names,
164
+ snapshot_id = save_snapshot(
165
+ sm, config, homotopy_result.x_solution, fs.var_names,
196
166
  metadata=current_metadata,
197
167
  parent_snapshot_id=state.snapshot_id if state is not None and not topology_changed else None,
198
168
  label="homotopy solution",
199
169
  )
200
170
  run_info = build_run_info(config, x0=fs.x0, var_names=fs.var_names)
201
- _persist_run(sm, fs, results, run_info, config, base_name, snapshot_id)
171
+ run_id = make_run_id()
172
+ persist_run(sm, tmp_fs, run_id, tmp_fs.results, run_info, config, base_name, snapshot_id)
202
173
  logger.info("Homotopy apply succeeded. New snapshot saved.")
203
174
  return
204
175
 
@@ -210,8 +181,8 @@ def apply(
210
181
  breakdown = log_residual_breakdown(fs)
211
182
  divergence = build_divergence_report(
212
183
  drifted_params=drifted,
213
- solver_stats=hom_stats,
214
- x_last=x_hom,
184
+ solver_stats=homotopy_result.stats,
185
+ x_last=homotopy_result.x_solution,
215
186
  var_names=fs.var_names,
216
187
  breakdown=breakdown,
217
188
  )
@@ -0,0 +1,99 @@
1
+ """Shared run-persistence helpers for the ``pf run`` / ``pf apply`` CLIs.
2
+
3
+ Both commands build a :class:`~processforge.types.RunManifest`, save it to the
4
+ :class:`~processforge.persistence.archive.ProcessStateArchive`, and write a Zarr
5
+ copy of the standardized outputs. That logic was previously duplicated (with
6
+ slightly different bugs in each copy); it now lives here in one place.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ import hashlib
13
+ import json
14
+ import os
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from loguru import logger
18
+
19
+ if TYPE_CHECKING:
20
+ from ..persistence.archive import ProcessStateArchive
21
+ from ..types import RunInfo
22
+
23
+ _RUN_ID_TS_FMT = "%Y%m%dT%H%M%SZ"
24
+
25
+
26
+ def make_run_id() -> str:
27
+ """Build a unique, sortable run id: ``<UTC timestamp>_<6 hex bytes>``."""
28
+ return (
29
+ datetime.datetime.now(datetime.timezone.utc).strftime(_RUN_ID_TS_FMT)
30
+ + "_"
31
+ + os.urandom(3).hex()
32
+ )
33
+
34
+
35
+ def flowsheet_hash(config: dict) -> str:
36
+ """Short (16-hex) sha256 of a flowsheet config for run/index correlation."""
37
+ return hashlib.sha256(
38
+ json.dumps(config, sort_keys=True, default=str).encode("utf-8")
39
+ ).hexdigest()[:16]
40
+
41
+
42
+ def persist_run(
43
+ archive: "ProcessStateArchive",
44
+ fs: Any,
45
+ run_id: str,
46
+ results: dict,
47
+ run_info: "RunInfo | dict",
48
+ config: dict,
49
+ base_name: str,
50
+ snapshot_id: str | None = None,
51
+ ) -> str:
52
+ """Persist a run's manifest + Zarr outputs into *archive*.
53
+
54
+ Args:
55
+ archive: The :class:`ProcessStateArchive` for this flowsheet.
56
+ fs: The flowsheet object that produced *results* (must expose
57
+ ``collect_outputs``). Either ``EOFlowsheet`` or ``Flowsheet``.
58
+ run_id: Unique run id (use :func:`make_run_id`).
59
+ results: The solve's stream/unit result dict (the *converged* one).
60
+ run_info: Provenance from
61
+ :func:`processforge.provenance.build_run_info`.
62
+ config: Validated flowsheet config (for the simulation mode).
63
+ base_name: Flowsheet basename used for archive paths.
64
+ snapshot_id: Optional snapshot id to stamp on the manifest (so a run
65
+ can be linked back to the state it was derived from).
66
+
67
+ Returns:
68
+ The run id that was persisted.
69
+ """
70
+ mode = config.get("simulation", {}).get("mode", "steady")
71
+ run_info_dump = (
72
+ run_info.model_dump() if hasattr(run_info, "model_dump") else run_info
73
+ )
74
+ manifest = fs.collect_outputs(
75
+ run_id, mode, base_name, provenance=run_info_dump
76
+ )
77
+ if snapshot_id is not None:
78
+ manifest.snapshot_id = snapshot_id
79
+
80
+ stream_results = {k: v for k, v in results.items() if not hasattr(v, "fields")}
81
+ archive.save_run(manifest, stream_results=stream_results)
82
+
83
+ # Always persist a Zarr copy of the standardized outputs (fields + artifacts)
84
+ # inside the archive, mirroring `pf run`.
85
+ try:
86
+ from ..result import relink_latest_results, save_results_zarr
87
+
88
+ run_results_dir = os.path.join(archive.path, "results", run_id)
89
+ save_results_zarr(
90
+ results,
91
+ os.path.join(run_results_dir, "results.zarr"),
92
+ run_info,
93
+ )
94
+ relink_latest_results(archive.path, run_id)
95
+ except Exception as e:
96
+ logger.warning(f"Failed to write results.zarr: {type(e).__name__}: {e}")
97
+
98
+ logger.info(f"Run saved : {run_id}")
99
+ return run_id
@@ -1,7 +1,6 @@
1
1
  """``pf run`` — run a process simulation from a flowsheet JSON file."""
2
2
  from __future__ import annotations
3
3
 
4
- import hashlib
5
4
  import os
6
5
  from datetime import datetime, timezone
7
6
 
@@ -21,22 +20,7 @@ from .common import (
21
20
  validate_runtime_flowsheet,
22
21
  _resolve_provider_url,
23
22
  )
24
-
25
-
26
- def _flowsheet_hash(config: dict) -> str:
27
- return hashlib.sha256(
28
- json_dumps(config).encode("utf-8")
29
- ).hexdigest()[:16]
30
-
31
-
32
- def json_dumps(obj) -> str:
33
- import json
34
-
35
- return json.dumps(obj, sort_keys=True, default=str)
36
-
37
-
38
- def _run_id() -> str:
39
- return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "_" + os.urandom(3).hex()
23
+ from .persist import flowsheet_hash, make_run_id, persist_run
40
24
 
41
25
 
42
26
  def _log_active_providers(config: dict) -> None:
@@ -99,8 +83,8 @@ def run(
99
83
  is_dynamic = mode == "dynamic"
100
84
 
101
85
  # Standardized run id, shared with container-side artifact uploads.
102
- run_id = _run_id()
103
- flowsheet_hash = _flowsheet_hash(config)
86
+ run_id = make_run_id()
87
+ flowsheet_hash_value = flowsheet_hash(config)
104
88
 
105
89
  if is_dynamic:
106
90
  # Load .pfarchive snapshot as t=0 if available.
@@ -164,17 +148,12 @@ def run(
164
148
  # are keyed by the same run_id/flowsheet_hash as this archive run.
165
149
  for provider in getattr(fs, "_provider_map", {}).values():
166
150
  if hasattr(provider, "set_run_context"):
167
- provider.set_run_context(run_id, flowsheet_hash)
151
+ provider.set_run_context(run_id, flowsheet_hash_value)
168
152
 
169
153
  # Build + persist the standardized run manifest.
170
154
  archive_path = os.path.join(outputs_dir, f"{base_name}.pfarchive")
171
155
  archive = ProcessStateArchive(archive_path)
172
- manifest = fs.collect_outputs(run_id, mode, base_name, provenance=run_info.model_dump())
173
- stream_results = {
174
- k: v for k, v in results.items()
175
- if not hasattr(v, "fields") # exclude EngineOutput objects
176
- }
177
- archive.save_run(manifest, stream_results=stream_results)
156
+ persist_run(archive, fs, run_id, results, run_info, config, base_name)
178
157
 
179
158
  # Persist a StateManager snapshot so `pf plan` (and `pf apply`) can diff
180
159
  # against this run as a baseline. Without it, `pf plan` always reports
@@ -191,27 +170,11 @@ def run(
191
170
  except Exception as e:
192
171
  logger.warning(f"Failed to save state snapshot: {type(e).__name__}: {e}")
193
172
 
194
- # Always persist a Zarr copy of the standardized outputs (fields + artifacts)
195
- # inside the archive. Works for every provider (OpenMC, CoolProp, FESTIM,
196
- # Cantera) and for both local and remote docker runs — remote artifacts
197
- # reference their S3 remote_uris.
198
- try:
199
- from ..result import relink_latest_results, save_results_zarr
200
-
201
- run_results_dir = os.path.join(archive.path, "results", run_id)
202
- save_results_zarr(
203
- results,
204
- os.path.join(run_results_dir, "results.zarr"),
205
- run_info,
206
- )
207
- relink_latest_results(archive.path, run_id)
208
- except Exception as e:
209
- logger.warning(f"Failed to write results.zarr: {type(e).__name__}: {e}")
210
-
211
173
  logger.info(f"Backend : {display_backend(config, getattr(fs, 'backend', 'dynamic'))}")
212
174
  logger.info(f"Run saved : {os.path.join(archive_path, 'runs', run_id + '.json')}")
213
175
 
214
176
  # Summarize standardized outputs.
177
+ manifest = archive.load_run(run_id)
215
178
  for unit_name, out in manifest.units.items():
216
179
  for f in out.fields:
217
180
  logger.info(f" [{unit_name}] {f.name} = {f.quantity.value} {f.quantity.unit}")
@@ -93,24 +93,58 @@ class EOFlowsheet:
93
93
  )
94
94
  except Exception: # noqa: BLE001
95
95
  self.residual_breakdown = []
96
- results = manager.extract_results(x_sol)
97
-
98
- # Run standalone SolverUnit instances (no inlet/outlet streams —
99
- # they delegate entirely to their provider's run_simulation()).
100
- from processforge.units.solver_unit import SolverUnit
101
- for unit_name, unit in self._unit_objects.items():
102
- if isinstance(unit, SolverUnit):
103
- logger.info(f"EOFlowsheet: running standalone SolverUnit '{unit_name}'")
104
- unit_result = unit._run_impl({})
105
- results[unit_name] = unit_result
106
- self.engine_outputs[unit_name] = unit_result
96
+ results = self.assemble_from_solution(manager, x_sol, converged, stats)
107
97
 
108
98
  logger.info("EOFlowsheet: simulation complete.")
109
- self.results = results
110
99
  return results
111
100
  finally:
112
101
  teardown_providers(self._provider_map)
113
102
 
103
+ def assemble_from_solution(
104
+ self,
105
+ manager: "GlobalJacobianManager",
106
+ x_sol: "np.ndarray",
107
+ converged: bool,
108
+ stats: dict,
109
+ ) -> dict:
110
+ """Populate run outputs from an already-computed solution vector.
111
+
112
+ Used both by :meth:`run` (after the Newton solve) and by callers such as
113
+ the homotopy fallback in ``pf apply``, where a solved ``x`` is produced
114
+ externally and must be turned into the same standardized ``results`` that
115
+ :meth:`run` would have returned. This keeps the post-solve assembly in
116
+ exactly one place.
117
+
118
+ Args:
119
+ manager: The live Jacobian manager used to build ``x_sol``.
120
+ x_sol: The converged (or best-effort) solution vector.
121
+ converged: Whether the solve was judged converged.
122
+ stats: Solver statistics dict (e.g. ``final_norm``, ``iterations``).
123
+
124
+ Returns:
125
+ The stream/unit result dict (also stored on ``self.results``).
126
+ """
127
+ self.x_converged = x_sol
128
+ self.converged = converged
129
+ self.solver_stats = stats
130
+
131
+ results = manager.extract_results(x_sol)
132
+
133
+ # Run standalone SolverUnit instances (no inlet/outlet streams —
134
+ # they delegate entirely to their provider's run_simulation()).
135
+ from processforge.units.solver_unit import SolverUnit
136
+
137
+ self.engine_outputs = {}
138
+ for unit_name, unit in self._unit_objects.items():
139
+ if isinstance(unit, SolverUnit):
140
+ logger.info(f"EOFlowsheet: running standalone SolverUnit '{unit_name}'")
141
+ unit_result = unit._run_impl({})
142
+ results[unit_name] = unit_result
143
+ self.engine_outputs[unit_name] = unit_result
144
+
145
+ self.results = results
146
+ return results
147
+
114
148
  # ------------------------------------------------------------------
115
149
  # Standardized output collection
116
150
  # ------------------------------------------------------------------
@@ -6,12 +6,14 @@ from typing import TYPE_CHECKING, Optional
6
6
 
7
7
  import numpy as np
8
8
  from loguru import logger
9
+ from pydantic import BaseModel, ConfigDict
9
10
 
10
11
  from .backends import ScipyBackend, PyomoBackend, CasADiBackend
11
12
  from .backends.base import AbstractEOBackend
12
13
  from ..types import SnapshotState
13
14
 
14
15
  if TYPE_CHECKING:
16
+ from .flowsheet import EOFlowsheet
15
17
  from .jacobian import GlobalJacobianManager
16
18
 
17
19
 
@@ -29,9 +31,18 @@ _BACKENDS: dict[str, type[AbstractEOBackend]] = {
29
31
  }
30
32
 
31
33
 
34
+ class HomotopyResult(BaseModel):
35
+ """Typed outcome of :func:`solve_with_homotopy`."""
36
+
37
+ model_config = ConfigDict(arbitrary_types_allowed=True)
38
+
39
+ x_solution: np.ndarray
40
+ converged: bool
41
+ stats: dict
42
+
43
+
32
44
  class EOSolver:
33
- """
34
- Selects and runs the EO backend specified by ``backend`` name.
45
+ """Selects and runs the EO backend specified by ``backend`` name.
35
46
 
36
47
  Args:
37
48
  backend: One of ``"scipy"``, ``"pyomo"``, ``"casadi"``.
@@ -117,22 +128,40 @@ def compute_residual_breakdown(
117
128
 
118
129
 
119
130
  def solve_with_homotopy(
120
- fs, manager, solver, state, drifted
121
- ) -> "tuple[np.ndarray, bool, dict]":
122
- """Try standard solve; if fails, fall back to step-wise homotopy."""
131
+ flowsheet: "EOFlowsheet",
132
+ manager: "GlobalJacobianManager",
133
+ solver: "EOSolver",
134
+ state: "SnapshotState | dict",
135
+ drifted: list[str],
136
+ ) -> "HomotopyResult":
137
+ """Try a standard solve; if it fails, fall back to step-wise homotopy.
138
+
139
+ Args:
140
+ flowsheet: The current-config :class:`EOFlowsheet` (used for its config
141
+ and backend).
142
+ manager: The live Jacobian manager for the current config.
143
+ solver: The :class:`EOSolver` instance driving each continuation step.
144
+ state: The prior converged snapshot (object or dict) used as the warm
145
+ start.
146
+ drifted: Dotted config paths that changed between the snapshot and the
147
+ current flowsheet.
148
+
149
+ Returns:
150
+ A :class:`HomotopyResult` holding the best solution vector found, whether
151
+ it converged, and the associated solver statistics.
152
+ """
123
153
  old_config = state.config if isinstance(state, SnapshotState) else state["config"]
124
- current_config = fs.config
154
+ current_config = flowsheet.config
125
155
 
126
- def update_config_value(cfg, path, value):
127
- from copy import deepcopy
128
- parts = path.split('.')
156
+ def update_config_value(cfg: dict, path: str, value: float) -> None:
157
+ parts = path.split(".")
129
158
  d = cfg
130
159
  for p in parts[:-1]:
131
160
  d = d[p]
132
161
  d[parts[-1]] = float(value)
133
-
134
- def get_config_value(cfg, path):
135
- parts = path.split('.')
162
+
163
+ def get_config_value(cfg: dict, path: str):
164
+ parts = path.split(".")
136
165
  d = cfg
137
166
  for p in parts[:-1]:
138
167
  if p not in d:
@@ -144,10 +173,14 @@ def solve_with_homotopy(
144
173
  _DEFAULT_SIGNALS = {
145
174
  "openmc": ConvergenceSignal(signal_key="k_eff", target=1.0, tolerance=0.01),
146
175
  }
147
- provider = current_config.get("units", {}).get(
148
- list(current_config.get("units", {}).keys())[0], {}
149
- ).get("provider", "openmc") if current_config.get("units") else None
150
-
176
+ provider = (
177
+ current_config.get("units", {})
178
+ .get(list(current_config.get("units", {}).keys())[0], {})
179
+ .get("provider", "openmc")
180
+ if current_config.get("units")
181
+ else None
182
+ )
183
+
151
184
  active_signal = None
152
185
  if convergence_signal:
153
186
  provider_filter = convergence_signal.get("provider")
@@ -155,103 +188,109 @@ def solve_with_homotopy(
155
188
  active_signal = ConvergenceSignal(**convergence_signal)
156
189
  elif provider in _DEFAULT_SIGNALS:
157
190
  active_signal = _DEFAULT_SIGNALS[provider]
158
-
191
+
159
192
  if active_signal:
160
193
  logger.info(
161
194
  f"Homotopy convergence signal: {active_signal.signal_key} "
162
195
  f"target={active_signal.target}"
163
196
  )
164
197
 
165
- import numpy as np
166
-
167
198
  if not isinstance(state, SnapshotState):
168
- x0 = np.array(state["x"])
199
+ warm_start = np.array(state["x"])
169
200
  else:
170
- x0 = np.array(state.x)
201
+ warm_start = np.array(state.x)
171
202
 
172
- x_sol, converged, stats = solver.solve(manager, x0)
203
+ x_solution, converged, stats = solver.solve(manager, warm_start)
173
204
  if converged:
174
205
  logger.info("Standard solver converged with previous state's warm guess.")
175
- return x_sol, converged, stats
176
-
206
+ return HomotopyResult(x_solution=x_solution, converged=converged, stats=stats)
207
+
177
208
  logger.warning("Standard solve failed. Invoking Homotopy 'step-wise apply' solver...")
178
-
179
- drifts = []
180
- for d in drifted:
181
- old_val = get_config_value(old_config, d)
182
- new_val = get_config_value(current_config, d)
209
+
210
+ interpolated_drifts: list[tuple[str, float, float]] = []
211
+ for drift in drifted:
212
+ old_val = get_config_value(old_config, drift)
213
+ new_val = get_config_value(current_config, drift)
183
214
  if isinstance(old_val, (int, float)) and isinstance(new_val, (int, float)):
184
- drifts.append((d, old_val, new_val))
185
-
186
- if not drifts:
215
+ interpolated_drifts.append((drift, float(old_val), float(new_val)))
216
+
217
+ if not interpolated_drifts:
187
218
  logger.error("No continuous numerical parameters to interpolate. Homotopy fails.")
188
- return x_sol, False, stats
219
+ return HomotopyResult(x_solution=x_solution, converged=False, stats=stats)
189
220
 
190
- x_current = x0.copy()
221
+ x_current = warm_start.copy()
191
222
  best_x = x_current.copy()
192
- best_residual = float('inf')
223
+ best_residual = float("inf")
193
224
  best_step = 0
194
-
225
+
195
226
  from copy import deepcopy
227
+
196
228
  interpolated_config = deepcopy(old_config)
197
229
  steps = 10
198
-
230
+
199
231
  from .flowsheet import EOFlowsheet
200
232
  from processforge.providers.manager import teardown_providers
201
-
233
+
202
234
  for step in range(1, steps + 1):
203
235
  alpha = step / steps
204
236
  logger.info(f"--- Homotopy Step {step}/{steps} (alpha={alpha:.1f}) ---")
205
-
206
- for d, old_val, new_val in drifts:
207
- val = old_val + alpha * (new_val - old_val)
208
- update_config_value(interpolated_config, d, val)
209
-
210
- step_fs = EOFlowsheet(interpolated_config, backend=fs.backend)
237
+
238
+ for path, old_val, new_val in interpolated_drifts:
239
+ value = old_val + alpha * (new_val - old_val)
240
+ update_config_value(interpolated_config, path, value)
241
+
242
+ step_flowsheet = EOFlowsheet(interpolated_config, backend=flowsheet.backend)
211
243
  try:
212
- step_manager = step_fs._build()
244
+ step_manager = step_flowsheet._build()
213
245
  step_x0 = x_current.copy()
214
- step_fs.x0 = step_x0
215
- step_fs.var_names = step_fs._build_var_names(step_manager)
216
-
217
- step_solver = EOSolver(backend=fs.backend, tol=solver.tol, max_iter=solver.max_iter)
218
- x_current, step_converged, s_stats = step_solver.solve(step_manager, step_x0)
219
-
220
- current_residual = s_stats.get('final_norm', float('inf'))
221
-
246
+ step_flowsheet.x0 = step_x0
247
+ step_flowsheet.var_names = step_flowsheet._build_var_names(step_manager)
248
+
249
+ step_solver = EOSolver(
250
+ backend=flowsheet.backend, tol=solver.tol, max_iter=solver.max_iter
251
+ )
252
+ x_current, step_converged, step_stats = step_solver.solve(step_manager, step_x0)
253
+
254
+ current_residual = step_stats.get("final_norm", float("inf"))
255
+
222
256
  if active_signal and active_signal.target is not None:
223
257
  signal_key = active_signal.signal_key
224
258
  signal_target = active_signal.target
225
259
  signal_tol = active_signal.tolerance
226
- all_scalars = {}
227
- for unit_name, unit_scalars in step_manager.scalars.items():
260
+ all_scalars: dict[str, float] = {}
261
+ for unit_scalars in step_manager.scalars.values():
228
262
  all_scalars.update(unit_scalars)
229
263
  signal_val = all_scalars.get(signal_key)
230
264
  if signal_val is not None:
231
265
  rel_error = abs(signal_val - signal_target) / signal_target
232
- logger.info(f" {signal_key}={signal_val:.4f}, target={signal_target}, rel_error={rel_error:.4f}")
266
+ logger.info(
267
+ f" {signal_key}={signal_val:.4f}, target={signal_target}, "
268
+ f"rel_error={rel_error:.4f}"
269
+ )
233
270
  if rel_error < signal_tol:
234
271
  logger.info(f" Converged via convergence signal at step {step}")
235
272
  best_x = x_current.copy()
236
273
  best_residual = current_residual
237
274
  best_step = step
238
275
  break
239
-
276
+
240
277
  if not step_converged:
241
- logger.error(f"Homotopy failed to converge at step {step} (alpha={alpha:.1f})")
278
+ logger.error(
279
+ f"Homotopy failed to converge at step {step} (alpha={alpha:.1f})"
280
+ )
242
281
  if best_step > 0:
243
282
  logger.info(f"Reverting to best checkpoint at step {best_step}")
244
283
  x_current = best_x.copy()
245
- return x_current, False, s_stats
246
-
284
+ return HomotopyResult(x_solution=x_current, converged=False, stats=step_stats)
285
+
247
286
  if current_residual < best_residual:
248
287
  best_residual = current_residual
249
288
  best_x = x_current.copy()
250
289
  best_step = step
251
-
290
+
252
291
  finally:
253
- teardown_providers(step_fs._provider_map)
254
-
292
+ teardown_providers(step_flowsheet._provider_map)
293
+
255
294
  logger.info("Homotopy step-wise solve successfully reached target config.")
256
- x_sol, conv, stats = solver.solve(manager, x_current)
257
- return x_sol, conv, stats
295
+ x_solution, converged, stats = solver.solve(manager, x_current)
296
+ return HomotopyResult(x_solution=x_solution, converged=converged, stats=stats)