processforge 0.3.25__tar.gz → 0.4.1__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 (152) hide show
  1. {processforge-0.3.25/src/processforge.egg-info → processforge-0.4.1}/PKG-INFO +5 -1
  2. {processforge-0.3.25 → processforge-0.4.1}/pyproject.toml +6 -1
  3. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/__init__.py +1 -1
  4. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/cli/__init__.py +1 -1
  5. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/cli/apply.py +21 -50
  6. processforge-0.4.1/src/processforge/cli/export_fmu.py +88 -0
  7. processforge-0.4.1/src/processforge/cli/persist.py +99 -0
  8. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/cli/run.py +6 -43
  9. processforge-0.4.1/src/processforge/cli/runs.py +201 -0
  10. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/flowsheet.py +46 -12
  11. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/solver.py +104 -65
  12. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/fmu/_fmi_vars.py +35 -2
  13. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/fmu/builder.py +60 -3
  14. processforge-0.4.1/src/processforge/fmu/interface.py +214 -0
  15. processforge-0.4.1/src/processforge/fmu/slave_template.py +572 -0
  16. processforge-0.4.1/src/processforge/pathsim/__init__.py +6 -0
  17. processforge-0.4.1/src/processforge/pathsim/adapter.py +184 -0
  18. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/result.py +135 -0
  19. {processforge-0.3.25 → processforge-0.4.1/src/processforge.egg-info}/PKG-INFO +5 -1
  20. {processforge-0.3.25 → processforge-0.4.1}/src/processforge.egg-info/SOURCES.txt +7 -0
  21. {processforge-0.3.25 → processforge-0.4.1}/src/processforge.egg-info/requires.txt +5 -0
  22. {processforge-0.3.25 → processforge-0.4.1}/tests/test_cli.py +14 -10
  23. processforge-0.4.1/tests/test_fmu_solverunit.py +281 -0
  24. processforge-0.4.1/tests/test_pathsim_adapter.py +66 -0
  25. processforge-0.4.1/tests/test_persist.py +73 -0
  26. {processforge-0.3.25 → processforge-0.4.1}/tests/test_runs_command.py +27 -4
  27. processforge-0.3.25/src/processforge/cli/export_fmu.py +0 -41
  28. processforge-0.3.25/src/processforge/cli/runs.py +0 -88
  29. processforge-0.3.25/src/processforge/fmu/slave_template.py +0 -252
  30. {processforge-0.3.25 → processforge-0.4.1}/LICENSE +0 -0
  31. {processforge-0.3.25 → processforge-0.4.1}/MANIFEST.in +0 -0
  32. {processforge-0.3.25 → processforge-0.4.1}/README.md +0 -0
  33. {processforge-0.3.25 → processforge-0.4.1}/flowsheets/closed-loop-chain.json +0 -0
  34. {processforge-0.3.25 → processforge-0.4.1}/flowsheets/coupled/msre_coupled.json +0 -0
  35. {processforge-0.3.25 → processforge-0.4.1}/flowsheets/custom/crimson/MUWS_HB.json +0 -0
  36. {processforge-0.3.25 → processforge-0.4.1}/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -0
  37. {processforge-0.3.25 → processforge-0.4.1}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
  38. {processforge-0.3.25 → processforge-0.4.1}/flowsheets/festim/tds_tungsten.json +0 -0
  39. {processforge-0.3.25 → processforge-0.4.1}/flowsheets/hydraulic-chain.json +0 -0
  40. {processforge-0.3.25 → processforge-0.4.1}/flowsheets/openmc/msre_eigenvalue.json +0 -0
  41. {processforge-0.3.25 → processforge-0.4.1}/setup.cfg +0 -0
  42. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/__main__.py +0 -0
  43. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/_schema.py +0 -0
  44. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/analysis/__init__.py +0 -0
  45. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/analysis/dof.py +0 -0
  46. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/api/__init__.py +0 -0
  47. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/api/serve.py +0 -0
  48. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/cli/common.py +0 -0
  49. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/cli/diagram.py +0 -0
  50. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/cli/display.py +0 -0
  51. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/cli/export_modelica.py +0 -0
  52. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/cli/init.py +0 -0
  53. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/cli/plan.py +0 -0
  54. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/cli/validate.py +0 -0
  55. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/compose.py +0 -0
  56. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/coupling.py +0 -0
  57. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/__init__.py +0 -0
  58. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/backends/__init__.py +0 -0
  59. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/backends/base.py +0 -0
  60. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/backends/casadi_backend.py +0 -0
  61. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  62. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/backends/scipy_backend.py +0 -0
  63. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/jacobian.py +0 -0
  64. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/mixin.py +0 -0
  65. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/stream_var.py +0 -0
  66. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/units/__init__.py +0 -0
  67. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/units/flash_eo.py +0 -0
  68. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/units/heater_eo.py +0 -0
  69. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/units/pipes_eo.py +0 -0
  70. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/units/pump_eo.py +0 -0
  71. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/units/strainer_eo.py +0 -0
  72. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/eo/units/valve_eo.py +0 -0
  73. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/flowsheet.py +0 -0
  74. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/fmu/__init__.py +0 -0
  75. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/lock.py +0 -0
  76. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/modelica/__init__.py +0 -0
  77. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/modelica/mo_writer.py +0 -0
  78. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/modelica/omc_runner.py +0 -0
  79. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/modelica/transpiler.py +0 -0
  80. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/modelica/unit_equations.py +0 -0
  81. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/output_collector.py +0 -0
  82. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/pcl/__init__.py +0 -0
  83. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/pcl/compiler.py +0 -0
  84. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/pcl/namespace.py +0 -0
  85. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/persistence/__init__.py +0 -0
  86. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/persistence/archive.py +0 -0
  87. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/persistence/artifact_store.py +0 -0
  88. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/provenance.py +0 -0
  89. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/__init__.py +0 -0
  90. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/base.py +0 -0
  91. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/base_jacobian_mixin.py +0 -0
  92. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/cantera_jacobian.py +0 -0
  93. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/cantera_provider.py +0 -0
  94. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/container_client.py +0 -0
  95. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/coolprop_provider.py +0 -0
  96. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/errors.py +0 -0
  97. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/festim_provider.py +0 -0
  98. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/jacobian_contributor.py +0 -0
  99. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/manager.py +0 -0
  100. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/modelica_jacobian.py +0 -0
  101. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/modelica_provider.py +0 -0
  102. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/openmc_provider.py +0 -0
  103. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/reference_state_registry.py +0 -0
  104. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/providers/registry.py +0 -0
  105. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/quantity.py +0 -0
  106. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/result_schema.py +0 -0
  107. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/schemas/__init__.py +0 -0
  108. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/schemas/festim/festim_model.py +0 -0
  109. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/schemas/flowsheet_schema.json +0 -0
  110. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/schemas/openmc/openmc_model.py +0 -0
  111. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/simulate.py +0 -0
  112. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/solver.py +0 -0
  113. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/state.py +0 -0
  114. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/thermo.py +0 -0
  115. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/types.py +0 -0
  116. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/__init__.py +0 -0
  117. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/base.py +0 -0
  118. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/cstr.py +0 -0
  119. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/flash.py +0 -0
  120. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/heater.py +0 -0
  121. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/pfr.py +0 -0
  122. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/pipes.py +0 -0
  123. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/pump.py +0 -0
  124. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/registry.py +0 -0
  125. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/solver_unit.py +0 -0
  126. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/strainer.py +0 -0
  127. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/tank.py +0 -0
  128. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/units/valve.py +0 -0
  129. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/utils/__init__.py +0 -0
  130. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/utils/flowsheet_diagram.py +0 -0
  131. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/utils/mermaid_diagram.py +0 -0
  132. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/utils/s3_upload.py +0 -0
  133. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/utils/topology.py +0 -0
  134. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/utils/unit_consistency.py +0 -0
  135. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/utils/validate_flowsheet.py +0 -0
  136. {processforge-0.3.25 → processforge-0.4.1}/src/processforge/utils/validation.py +0 -0
  137. {processforge-0.3.25 → processforge-0.4.1}/src/processforge.egg-info/dependency_links.txt +0 -0
  138. {processforge-0.3.25 → processforge-0.4.1}/src/processforge.egg-info/entry_points.txt +0 -0
  139. {processforge-0.3.25 → processforge-0.4.1}/src/processforge.egg-info/top_level.txt +0 -0
  140. {processforge-0.3.25 → processforge-0.4.1}/tests/test_container_client.py +0 -0
  141. {processforge-0.3.25 → processforge-0.4.1}/tests/test_coupling.py +0 -0
  142. {processforge-0.3.25 → processforge-0.4.1}/tests/test_display.py +0 -0
  143. {processforge-0.3.25 → processforge-0.4.1}/tests/test_festim_provider.py +0 -0
  144. {processforge-0.3.25 → processforge-0.4.1}/tests/test_festim_schema.py +0 -0
  145. {processforge-0.3.25 → processforge-0.4.1}/tests/test_init.py +0 -0
  146. {processforge-0.3.25 → processforge-0.4.1}/tests/test_openmc_provider.py +0 -0
  147. {processforge-0.3.25 → processforge-0.4.1}/tests/test_provider_errors.py +0 -0
  148. {processforge-0.3.25 → processforge-0.4.1}/tests/test_providers.py +0 -0
  149. {processforge-0.3.25 → processforge-0.4.1}/tests/test_result_zarr.py +0 -0
  150. {processforge-0.3.25 → processforge-0.4.1}/tests/test_s3_upload.py +0 -0
  151. {processforge-0.3.25 → processforge-0.4.1}/tests/test_state.py +0 -0
  152. {processforge-0.3.25 → processforge-0.4.1}/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.4.1
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
@@ -43,9 +43,13 @@ Requires-Dist: pyomo>=6.7; extra == "eo-casadi"
43
43
  Requires-Dist: casadi>=3.6; extra == "eo-casadi"
44
44
  Provides-Extra: fmu
45
45
  Requires-Dist: pythonfmu>=0.6; extra == "fmu"
46
+ Requires-Dist: fmpy>=0.3; extra == "fmu"
46
47
  Provides-Extra: modelica
47
48
  Requires-Dist: OMPython>=4.0.0; extra == "modelica"
48
49
  Requires-Dist: fmpy>=0.3; extra == "modelica"
50
+ Provides-Extra: pathsim
51
+ Requires-Dist: pathsim>=0.9; extra == "pathsim"
52
+ Requires-Dist: fmpy>=0.3; extra == "pathsim"
49
53
  Provides-Extra: cantera
50
54
  Requires-Dist: cantera>=3.0; extra == "cantera"
51
55
  Provides-Extra: coolprop
@@ -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.4.1"
8
8
  description = "A Python-based process simulation framework for chemical engineering applications."
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -55,11 +55,16 @@ eo-casadi = [
55
55
  ]
56
56
  fmu = [
57
57
  "pythonfmu>=0.6",
58
+ "fmpy>=0.3",
58
59
  ]
59
60
  modelica = [
60
61
  "OMPython>=4.0.0",
61
62
  "fmpy>=0.3",
62
63
  ]
64
+ pathsim = [
65
+ "pathsim>=0.9",
66
+ "fmpy>=0.3",
67
+ ]
63
68
  cantera = [
64
69
  "cantera>=3.0",
65
70
  ]
@@ -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.4.1"
34
34
 
35
35
  __all__ = [
36
36
  "Flowsheet",
@@ -32,7 +32,7 @@ def register_commands(app: typer.Typer) -> None:
32
32
  )(apply)
33
33
  app.command(
34
34
  "runs",
35
- help="List runs for a flowsheet, or show one run's full manifest (pf runs <flowsheet> [<run_id>])",
35
+ help="List runs for a flowsheet, or inspect one run's Zarr result summary (pf runs <flowsheet> [<run_id>|latest] [--schema])",
36
36
  )(runs)
37
37
  app.command(
38
38
  "plan",
@@ -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,88 @@
1
+ """``pf export-fmu`` — export flowsheet as FMI 2.0 co-simulation FMU."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Literal
7
+
8
+ import typer
9
+ from loguru import logger
10
+
11
+ from .common import require_existing_file
12
+
13
+
14
+ def export_fmu(
15
+ flowsheet: str = typer.Argument(help="Path to the flowsheet JSON file"),
16
+ output_dir: str = typer.Option(
17
+ "outputs",
18
+ "--output-dir",
19
+ "-o",
20
+ help="Directory for the output FMU (default: outputs/)",
21
+ ),
22
+ backend: Literal["scipy", "pyomo", "casadi"] = typer.Option(
23
+ "scipy",
24
+ "--backend",
25
+ help="EO solver backend for steady-state mode (default: scipy)",
26
+ ),
27
+ pathsim: bool = typer.Option(
28
+ False,
29
+ "--pathsim",
30
+ help="Also emit the fmu_interface.json manifest and a PathSim wiring example",
31
+ ),
32
+ ) -> None:
33
+ """Export a flowsheet as an FMI 2.0 co-simulation FMU."""
34
+ require_existing_file(flowsheet)
35
+
36
+ from ..fmu import build_fmu # local import — pythonfmu is optional
37
+
38
+ output_dir = output_dir or "outputs"
39
+ backend = backend or "scipy"
40
+
41
+ try:
42
+ fmu_path = build_fmu(
43
+ flowsheet,
44
+ output_dir=output_dir,
45
+ backend=backend,
46
+ write_manifest=pathsim,
47
+ )
48
+ logger.info(f"FMU written to: {fmu_path}")
49
+ if pathsim:
50
+ base = os.path.splitext(os.path.basename(fmu_path))[0]
51
+ manifest_path = os.path.join(output_dir, f"{base}_fmu_interface.json")
52
+ example_path = os.path.join(output_dir, f"{base}_pathsim.py")
53
+ _write_pathsim_example(example_path, fmu_path, manifest_path)
54
+ logger.info(f"Manifest written to: {manifest_path}")
55
+ logger.info(f"PathSim example written to: {example_path}")
56
+ except Exception as e:
57
+ logger.error(f"FMU export failed: {type(e).__name__}: {e}")
58
+ logger.debug("FMU export traceback:", exc_info=True)
59
+ raise SystemExit(1)
60
+
61
+
62
+ def _write_pathsim_example(example_path: str, fmu_path: str, manifest_path: str) -> None:
63
+ """Write a minimal PathSim wiring example next to the exported FMU."""
64
+ content = f'''"""Auto-generated PathSim wiring example."""
65
+ import pathsim as ps
66
+ from processforge.pathsim import ProcessForgeFMU
67
+
68
+ plant = ProcessForgeFMU(
69
+ fmu_path={fmu_path!r},
70
+ manifest_path={manifest_path!r},
71
+ )
72
+
73
+ # Inspect available ports:
74
+ # print(plant.inputs)
75
+ # print(plant.outputs)
76
+
77
+ # Example: drive an input with a constant setpoint and read an output.
78
+ setpoint = ps.Constant(val=1.0)
79
+ sim = ps.Simulation(
80
+ blocks=[setpoint, plant],
81
+ connections=[
82
+ # ps.Connection(setpoint, plant["<unit>.<input>"]),
83
+ ],
84
+ )
85
+ sim.run(time_span=(0, 10))
86
+ '''
87
+ with open(example_path, "w", encoding="utf-8") as f:
88
+ f.write(content)
@@ -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}")
@@ -0,0 +1,201 @@
1
+ """``pf runs`` — list runs and inspect their Zarr result summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+
8
+ import arrow
9
+ import typer
10
+ from loguru import logger
11
+
12
+ from .common import flowsheet_basename, output_root
13
+ from ..persistence.archive import ProcessStateArchive
14
+ from ..result import _fmt_scientific, _one_line_summary, summarize_zarr_store
15
+
16
+
17
+ def _humanize_timestamp(ts: str) -> str:
18
+ """Render a stored timestamp as a human-readable relative string.
19
+
20
+ Falls back to the raw string for values that aren't parseable ISO-8601
21
+ (e.g. ``20260101T000000Z`` used in some test fixtures).
22
+ """
23
+ try:
24
+ return arrow.get(ts).humanize()
25
+ except (arrow.parser.ParserError, ValueError, TypeError):
26
+ return ts
27
+
28
+
29
+ def _zarr_path(archive_path: str, run_id: str) -> str:
30
+ """Return the per-run Zarr store path."""
31
+ return os.path.join(archive_path, "results", run_id, "results.zarr")
32
+
33
+
34
+ def _artifact_status(artifact: dict) -> str:
35
+ """Return a short presence marker for an artifact dict."""
36
+ local_path = artifact.get("local_path")
37
+ remote_uris = artifact.get("remote_uris") or []
38
+ if local_path and os.path.exists(local_path):
39
+ return "local✓"
40
+ if remote_uris:
41
+ return "remote-only"
42
+ return "missing"
43
+
44
+
45
+ def _format_run_details(manifest, summary: dict) -> str:
46
+ """Format a detailed, human-readable result summary for a single run."""
47
+ lines = [
48
+ f"Run: {manifest.run_id}",
49
+ f"Timestamp: {manifest.timestamp}",
50
+ f"Mode: {summary.get('mode', manifest.mode)}",
51
+ "",
52
+ "Results:",
53
+ "────────",
54
+ ]
55
+
56
+ engine_outputs = summary.get("engine_outputs", {})
57
+ if engine_outputs:
58
+ for name, eo in engine_outputs.items():
59
+ engine = eo.get("engine", "")
60
+ sim_type = eo.get("sim_type", "")
61
+ status = eo.get("status", "")
62
+ lines.append(f"{name} ({engine} / {sim_type}) [{status}]")
63
+ for fname, fdata in eo.get("fields", {}).items():
64
+ val = _fmt_scientific(fdata.get("value"))
65
+ unit = fdata.get("unit", "")
66
+ std = fdata.get("std_dev")
67
+ if std is not None:
68
+ lines.append(f" {fname}: {val} ± {_fmt_scientific(std)} {unit}".strip())
69
+ else:
70
+ lines.append(f" {fname}: {val} {unit}".strip())
71
+ if eo.get("attrs") and not eo.get("fields"):
72
+ for k, v in eo["attrs"].items():
73
+ if not k.startswith("_") and k not in ("engine", "sim_type", "status"):
74
+ lines.append(f" {k}: {_fmt_scientific(v)}")
75
+ lines.append("")
76
+ else:
77
+ lines.append(" (no solver/engine outputs)")
78
+ lines.append("")
79
+
80
+ streams = summary.get("streams", {})
81
+ if streams:
82
+ lines.append("Streams:")
83
+ all_vars: set[str] = set()
84
+ for sdata in streams.values():
85
+ all_vars.update(sdata.get("variables", []))
86
+ all_vars = sorted(all_vars)
87
+
88
+ header_parts = ["stream".ljust(20)] + [v.rjust(14) for v in all_vars]
89
+ lines.append(" " + " ".join(header_parts))
90
+ lines.append(" " + "-" * len(" ".join(header_parts)))
91
+ for sname in sorted(streams):
92
+ sdata = streams[sname]
93
+ fields = sdata.get("fields", {})
94
+ row = [sname.ljust(20)]
95
+ for var in all_vars:
96
+ fdata = fields.get(var, {})
97
+ val = fdata.get("value")
98
+ row.append(_fmt_scientific(val).rjust(14))
99
+ lines.append(" " + " ".join(row))
100
+ lines.append("")
101
+
102
+ artifacts = summary.get("artifacts", [])
103
+ if artifacts:
104
+ lines.append("Artifacts:")
105
+ for art in artifacts:
106
+ name = art.get("name", "unknown")
107
+ kind = art.get("kind", "")
108
+ status = _artifact_status(art)
109
+ loc = art.get("local_path") or (art.get("remote_uris") or ["no location"])[0]
110
+ lines.append(f" [{kind}] {name}: {status} ({loc})")
111
+ else:
112
+ lines.append("Artifacts: none")
113
+
114
+ zarr_path = summary.get("_zarr_path")
115
+ if zarr_path:
116
+ lines.append("")
117
+ lines.append(f"Zarr store: {zarr_path}")
118
+
119
+ return "\n".join(lines)
120
+
121
+
122
+ def runs(
123
+ flowsheet: str = typer.Argument(..., help="Path to the flowsheet JSON"),
124
+ run_id: str = typer.Argument(
125
+ None,
126
+ help="Show this run's result summary. Use 'latest' for the most recent run.",
127
+ ),
128
+ schema: bool = typer.Option(
129
+ False,
130
+ "--schema",
131
+ help="Print the result schema JSON instead of the summary.",
132
+ ),
133
+ ):
134
+ """List runs for a flowsheet, or inspect one run's Zarr result summary."""
135
+ base = flowsheet_basename(flowsheet)
136
+ archive_path = os.path.join(output_root(), f"{base}.pfarchive")
137
+
138
+ if not os.path.isdir(archive_path):
139
+ logger.error(
140
+ f"No archive found for '{flowsheet}' at {archive_path}. "
141
+ "Run 'pf run' first."
142
+ )
143
+ raise typer.Exit(code=1)
144
+
145
+ archive = ProcessStateArchive(archive_path)
146
+ latest = archive.latest_run()
147
+ latest_id = latest.run_id if latest is not None else None
148
+
149
+ if run_id is not None:
150
+ resolved_id = latest_id if run_id == "latest" and latest_id else run_id
151
+ if resolved_id != run_id and latest_id is None:
152
+ logger.error(f"No latest run found in {archive_path}.")
153
+ raise typer.Exit(code=1)
154
+
155
+ if resolved_id not in archive.list_runs():
156
+ logger.error(f"Run '{resolved_id}' not found in {archive_path}/runs.")
157
+ raise typer.Exit(code=1)
158
+
159
+ zarr_path = _zarr_path(archive_path, resolved_id)
160
+
161
+ if schema:
162
+ schema_path = zarr_path + ".schema.json"
163
+ if not os.path.isfile(schema_path):
164
+ logger.error(f"No schema found for run '{resolved_id}'.")
165
+ raise typer.Exit(code=1)
166
+ with open(schema_path, "r", encoding="utf-8") as f:
167
+ typer.echo(f.read())
168
+ return
169
+
170
+ manifest = archive.load_run(resolved_id)
171
+ summary = summarize_zarr_store(zarr_path)
172
+ summary["_zarr_path"] = zarr_path
173
+ typer.echo(_format_run_details(manifest, summary))
174
+ return
175
+
176
+ # List all runs with a one-line summary.
177
+ run_ids = archive.list_runs()
178
+ if not run_ids:
179
+ typer.echo(f"No runs found for '{flowsheet}'.")
180
+ return
181
+
182
+ header = f"{'RUN ID':<28} {'TIMESTAMP':<22} {'LATEST':<7} {'SUMMARY':<30}"
183
+ typer.echo(header)
184
+ typer.echo("-" * len(header))
185
+ for rid in run_ids:
186
+ manifest = archive.load_run(rid)
187
+ if manifest is None:
188
+ continue
189
+ marker = "*" if rid == latest_id else " "
190
+ zarr_path = _zarr_path(archive_path, rid)
191
+ summary = summarize_zarr_store(zarr_path)
192
+ one_liner = _one_line_summary(summary)
193
+ typer.echo(
194
+ f"{rid:<28} {_humanize_timestamp(manifest.timestamp):<22} {marker:<7} {one_liner:<30}"
195
+ )
196
+ typer.echo("")
197
+ typer.echo(
198
+ "'*' marks the latest run. "
199
+ "'pf runs <flowsheet> <run_id>' shows the result summary. "
200
+ "Use 'latest' as the run id for the most recent run."
201
+ )