processforge 0.3.3__tar.gz → 0.3.5__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 (122) hide show
  1. {processforge-0.3.3/src/processforge.egg-info → processforge-0.3.5}/PKG-INFO +1 -1
  2. {processforge-0.3.3 → processforge-0.3.5}/pyproject.toml +1 -1
  3. processforge-0.3.5/src/processforge/cli/apply.py +209 -0
  4. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/cli/common.py +0 -44
  5. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/cli/init.py +17 -7
  6. processforge-0.3.5/src/processforge/cli/run.py +107 -0
  7. processforge-0.3.5/src/processforge/compose.py +52 -0
  8. processforge-0.3.5/src/processforge/providers/container_client.py +218 -0
  9. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/manager.py +15 -4
  10. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/openmc_provider.py +13 -1
  11. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/registry.py +9 -2
  12. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/types.py +2 -0
  13. {processforge-0.3.3 → processforge-0.3.5/src/processforge.egg-info}/PKG-INFO +1 -1
  14. {processforge-0.3.3 → processforge-0.3.5}/src/processforge.egg-info/SOURCES.txt +1 -0
  15. {processforge-0.3.3 → processforge-0.3.5}/tests/test_cli.py +4 -146
  16. processforge-0.3.3/src/processforge/cli/apply.py +0 -238
  17. processforge-0.3.3/src/processforge/cli/run.py +0 -137
  18. processforge-0.3.3/src/processforge/compose.py +0 -114
  19. {processforge-0.3.3 → processforge-0.3.5}/LICENSE +0 -0
  20. {processforge-0.3.3 → processforge-0.3.5}/MANIFEST.in +0 -0
  21. {processforge-0.3.3 → processforge-0.3.5}/README.md +0 -0
  22. {processforge-0.3.3 → processforge-0.3.5}/flowsheets/closed-loop-chain.json +0 -0
  23. {processforge-0.3.3 → processforge-0.3.5}/flowsheets/custom/crimson/MUWS_HB.json +0 -0
  24. {processforge-0.3.3 → processforge-0.3.5}/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -0
  25. {processforge-0.3.3 → processforge-0.3.5}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
  26. {processforge-0.3.3 → processforge-0.3.5}/flowsheets/hydraulic-chain.json +0 -0
  27. {processforge-0.3.3 → processforge-0.3.5}/flowsheets/openmc/msre_eigenvalue.json +0 -0
  28. {processforge-0.3.3 → processforge-0.3.5}/setup.cfg +0 -0
  29. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/__init__.py +0 -0
  30. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/__main__.py +0 -0
  31. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/_schema.py +0 -0
  32. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/analysis/__init__.py +0 -0
  33. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/analysis/dof.py +0 -0
  34. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/cli/__init__.py +0 -0
  35. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/cli/diagram.py +0 -0
  36. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/cli/display.py +0 -0
  37. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/cli/export_fmu.py +0 -0
  38. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/cli/export_modelica.py +0 -0
  39. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/cli/plan.py +0 -0
  40. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/cli/validate.py +0 -0
  41. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/__init__.py +0 -0
  42. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/backends/__init__.py +0 -0
  43. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/backends/base.py +0 -0
  44. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/backends/casadi_backend.py +0 -0
  45. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  46. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/backends/scipy_backend.py +0 -0
  47. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/flowsheet.py +0 -0
  48. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/jacobian.py +0 -0
  49. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/mixin.py +0 -0
  50. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/solver.py +0 -0
  51. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/stream_var.py +0 -0
  52. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/units/__init__.py +0 -0
  53. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/units/flash_eo.py +0 -0
  54. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/units/heater_eo.py +0 -0
  55. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/units/pipes_eo.py +0 -0
  56. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/units/pump_eo.py +0 -0
  57. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/units/strainer_eo.py +0 -0
  58. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/eo/units/valve_eo.py +0 -0
  59. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/flowsheet.py +0 -0
  60. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/fmu/__init__.py +0 -0
  61. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/fmu/_fmi_vars.py +0 -0
  62. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/fmu/builder.py +0 -0
  63. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/fmu/slave_template.py +0 -0
  64. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/lock.py +0 -0
  65. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/modelica/__init__.py +0 -0
  66. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/modelica/mo_writer.py +0 -0
  67. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/modelica/omc_runner.py +0 -0
  68. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/modelica/transpiler.py +0 -0
  69. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/modelica/unit_equations.py +0 -0
  70. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/pcl/__init__.py +0 -0
  71. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/pcl/compiler.py +0 -0
  72. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/pcl/namespace.py +0 -0
  73. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/provenance.py +0 -0
  74. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/__init__.py +0 -0
  75. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/base.py +0 -0
  76. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/base_jacobian_mixin.py +0 -0
  77. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/cantera_jacobian.py +0 -0
  78. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/cantera_provider.py +0 -0
  79. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/coolprop_provider.py +0 -0
  80. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/festim_provider.py +0 -0
  81. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/jacobian_contributor.py +0 -0
  82. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/modelica_jacobian.py +0 -0
  83. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/modelica_provider.py +0 -0
  84. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/providers/reference_state_registry.py +0 -0
  85. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/result.py +0 -0
  86. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/result_schema.py +0 -0
  87. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/schemas/__init__.py +0 -0
  88. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/schemas/flowsheet_schema.json +0 -0
  89. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/schemas/openmc/openmc_model.py +0 -0
  90. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/simulate.py +0 -0
  91. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/solver.py +0 -0
  92. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/state.py +0 -0
  93. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/thermo.py +0 -0
  94. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/__init__.py +0 -0
  95. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/base.py +0 -0
  96. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/cstr.py +0 -0
  97. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/flash.py +0 -0
  98. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/heater.py +0 -0
  99. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/pfr.py +0 -0
  100. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/pipes.py +0 -0
  101. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/pump.py +0 -0
  102. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/registry.py +0 -0
  103. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/solver_unit.py +0 -0
  104. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/strainer.py +0 -0
  105. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/tank.py +0 -0
  106. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/units/valve.py +0 -0
  107. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/utils/__init__.py +0 -0
  108. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/utils/flowsheet_diagram.py +0 -0
  109. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/utils/mermaid_diagram.py +0 -0
  110. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/utils/topology.py +0 -0
  111. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/utils/unit_consistency.py +0 -0
  112. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/utils/validate_flowsheet.py +0 -0
  113. {processforge-0.3.3 → processforge-0.3.5}/src/processforge/utils/validation.py +0 -0
  114. {processforge-0.3.3 → processforge-0.3.5}/src/processforge.egg-info/dependency_links.txt +0 -0
  115. {processforge-0.3.3 → processforge-0.3.5}/src/processforge.egg-info/entry_points.txt +0 -0
  116. {processforge-0.3.3 → processforge-0.3.5}/src/processforge.egg-info/requires.txt +0 -0
  117. {processforge-0.3.3 → processforge-0.3.5}/src/processforge.egg-info/top_level.txt +0 -0
  118. {processforge-0.3.3 → processforge-0.3.5}/tests/test_festim_provider.py +0 -0
  119. {processforge-0.3.3 → processforge-0.3.5}/tests/test_init.py +0 -0
  120. {processforge-0.3.3 → processforge-0.3.5}/tests/test_providers.py +0 -0
  121. {processforge-0.3.3 → processforge-0.3.5}/tests/test_state.py +0 -0
  122. {processforge-0.3.3 → processforge-0.3.5}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.3.3
3
+ Version: 0.3.5
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.3"
7
+ version = "0.3.5"
8
8
  description = "A Python-based process simulation framework for chemical engineering applications."
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -0,0 +1,209 @@
1
+ """``pf apply`` — state-based warm start with homotopy fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from typing import Literal
8
+
9
+ import typer
10
+ from loguru import logger
11
+
12
+ from ..eo import EOFlowsheet
13
+ from ..provenance import build_run_info
14
+ from ..result import save_results_zarr
15
+ from ..state import StateManager
16
+ from .common import (
17
+ build_divergence_report,
18
+ build_run_metadata,
19
+ check_providers,
20
+ flowsheet_basename,
21
+ load_state_manager,
22
+ log_residual_breakdown,
23
+ output_root,
24
+ require_existing_file,
25
+ save_snapshot,
26
+ validate_runtime_flowsheet,
27
+ write_divergence_report,
28
+ )
29
+
30
+ from .display import print_structural_diff
31
+
32
+
33
+ def apply(
34
+ flowsheet: str = typer.Argument(help="Path to the flowsheet JSON file"),
35
+ backend: Literal["scipy", "pyomo", "casadi"] | None = typer.Option(
36
+ None,
37
+ "--backend",
38
+ help="Override the flowsheet's simulation.backend",
39
+ ),
40
+ tolerance: float = typer.Option(
41
+ 1e-6,
42
+ "--tolerance",
43
+ help="Newton solver convergence tolerance (default: 1e-6)",
44
+ ),
45
+ max_iter: int = typer.Option(
46
+ 50,
47
+ "--max-iter",
48
+ help="Max Newton iterations (default: 50)",
49
+ ),
50
+ skip_homotopy: bool = typer.Option(
51
+ False,
52
+ "--skip-homotopy",
53
+ help="Disable homotopy fallback; cold-start only",
54
+ ),
55
+ ) -> None:
56
+ """Apply flowsheet: drift detection, warm-start, homotopy fallback, convergence guardrails."""
57
+ require_existing_file(flowsheet)
58
+ config = validate_runtime_flowsheet(flowsheet)
59
+
60
+ # Check provider availability (assumes any containers are already running)
61
+ check_providers(config, flowsheet)
62
+
63
+ base_name = flowsheet_basename(flowsheet)
64
+
65
+ sim_cfg = config.get("simulation", {})
66
+ mode = sim_cfg.get("mode", "steady")
67
+ if mode != "steady":
68
+ logger.error("pf apply is only supported for steady-state EO flowsheets.")
69
+ raise SystemExit(1)
70
+
71
+ outputs_dir = output_root()
72
+ os.makedirs(outputs_dir, exist_ok=True)
73
+ sm, state = load_state_manager(outputs_dir, base_name)
74
+
75
+ # Structural diff: detect topology changes
76
+ topology_changed = False
77
+ if state is not None:
78
+ diff = sm.detect_structural_diff(config, state)
79
+ print_structural_diff(diff)
80
+ topology_changed = bool(diff.get("added") or diff.get("removed"))
81
+ if topology_changed:
82
+ logger.warning(
83
+ "Topology changed (units added/removed). "
84
+ "Homotopy requires identical topology — falling back to cold start."
85
+ )
86
+
87
+ # Parameter drift (only meaningful when topology is unchanged)
88
+ drifted: list[str] = []
89
+ current_metadata = build_run_metadata(config, tolerance, max_iter, backend or "scipy")
90
+ if state is not None and not topology_changed:
91
+ mismatches = sm.validate_metadata(current_metadata, state)
92
+ if mismatches:
93
+ logger.warning(f"Metadata mismatch: {mismatches}")
94
+ drifted = sm.detect_drift(config, state)
95
+ if not drifted:
96
+ logger.info("No drift detected. System is already at the desired state.")
97
+ return
98
+ stream_drifts = [d for d in drifted if d.startswith("streams.")]
99
+ unit_drifts = [d for d in drifted if d.startswith("units.")]
100
+ logger.warning("Drift detected:")
101
+ if stream_drifts:
102
+ logger.warning(f" Stream drift : {stream_drifts}")
103
+ if unit_drifts:
104
+ logger.warning(f" Unit drift : {unit_drifts}")
105
+
106
+ # Build flowsheet; attach saved state for warm-start unless topology changed
107
+ fs = EOFlowsheet(config, backend=backend)
108
+ fs.saved_state = state if not topology_changed else None
109
+ fs.solver_tol = tolerance
110
+ fs.solver_max_iter = max_iter
111
+
112
+ logger.info("=== Running Apply (Steady-State EO) ===")
113
+ t0 = time.perf_counter()
114
+ results = fs.run()
115
+ elapsed = time.perf_counter() - t0
116
+
117
+ logger.info(
118
+ f"Direct solve completed in {elapsed:.2f}s (converged={fs.converged})."
119
+ )
120
+
121
+ if fs.converged:
122
+ snapshot_id = save_snapshot(
123
+ sm, config, fs.x_converged, fs.var_names,
124
+ metadata=current_metadata,
125
+ parent_snapshot_id=state.snapshot_id if state is not None and not topology_changed else None,
126
+ label="converged state",
127
+ )
128
+ run_info = build_run_info(config, x0=fs.x0, var_names=fs.var_names)
129
+ zarr_path = os.path.join(outputs_dir, f"{base_name}_results.zarr")
130
+ save_results_zarr(results, zarr_path, run_info=run_info)
131
+ logger.info("=== Apply Summary ===")
132
+ logger.info(" Status : CONVERGED")
133
+ logger.info(f" Final ||F|| : {fs.solver_stats.get('final_norm', '?'):.3e}")
134
+ logger.info(f" Iterations : {fs.solver_stats.get('iterations', '?')}")
135
+ logger.info(f" Backend : {fs.backend}")
136
+ logger.info(f" Snapshot ID : {snapshot_id}")
137
+ logger.info(f" Results zarr : {zarr_path}")
138
+ logger.info(f" Elapsed (s) : {elapsed:.2f}")
139
+ return
140
+
141
+ # Direct solve failed — try homotopy (only when topology is same and state exists)
142
+ if state is not None and not topology_changed and drifted and not skip_homotopy:
143
+ logger.warning("Direct solve failed. Attempting homotopy continuation...")
144
+ from ..eo.solver import EOSolver, solve_with_homotopy
145
+ from ..eo.flowsheet import EOFlowsheet as _EO
146
+ from ..providers.manager import teardown_providers
147
+
148
+ solver = EOSolver(backend=fs.backend, tol=tolerance, max_iter=max_iter)
149
+ tmp_fs = _EO(config, backend=backend)
150
+ manager = tmp_fs._build()
151
+ try:
152
+ x_hom, hom_converged, hom_stats = solve_with_homotopy(
153
+ tmp_fs, manager, solver, state, drifted
154
+ )
155
+ finally:
156
+ teardown_providers(tmp_fs._provider_map)
157
+
158
+ if hom_converged:
159
+ logger.info(
160
+ f"Homotopy converged: ||F||={hom_stats.get('final_norm', '?'):.3e}, "
161
+ f"iterations={hom_stats.get('iterations', '?')}"
162
+ )
163
+ save_snapshot(
164
+ sm, config, x_hom, fs.var_names,
165
+ metadata=current_metadata,
166
+ parent_snapshot_id=state.snapshot_id if state is not None and not topology_changed else None,
167
+ label="homotopy solution",
168
+ )
169
+ run_info = build_run_info(config, x0=fs.x0, var_names=fs.var_names)
170
+ save_results_zarr(
171
+ results,
172
+ os.path.join(outputs_dir, f"{base_name}_results.zarr"),
173
+ run_info=run_info,
174
+ )
175
+ logger.info("Homotopy apply succeeded. New snapshot saved.")
176
+ return
177
+
178
+ # Both failed — auto-revert and write divergence report
179
+ logger.error("Homotopy also failed to converge.")
180
+ prev_id = state.snapshot_id if state is not None else "unknown"
181
+ sm.rollback(1)
182
+ logger.warning(f"Reverted .pfstate to snapshot before {prev_id}.")
183
+ breakdown = log_residual_breakdown(fs)
184
+ divergence = build_divergence_report(
185
+ drifted_params=drifted,
186
+ solver_stats=hom_stats,
187
+ x_last=x_hom,
188
+ var_names=fs.var_names,
189
+ breakdown=breakdown,
190
+ )
191
+ else:
192
+ # Cold start also failed; no rollback (nothing to revert to)
193
+ if not drifted and state is None:
194
+ logger.error("Cold-start solve failed to converge (no prior snapshot).")
195
+ elif skip_homotopy:
196
+ logger.error("Cold-start solve failed to converge (--skip-homotopy).")
197
+ else:
198
+ logger.error("Cold-start solve failed to converge.")
199
+ breakdown = log_residual_breakdown(fs)
200
+ divergence = build_divergence_report(
201
+ drifted_params=[],
202
+ solver_stats=fs.solver_stats,
203
+ x_last=getattr(fs, "x_converged", []),
204
+ var_names=getattr(fs, "var_names", []),
205
+ breakdown=breakdown,
206
+ )
207
+
208
+ write_divergence_report(outputs_dir, base_name, divergence)
209
+ raise SystemExit(1)
@@ -203,50 +203,6 @@ def check_providers(
203
203
  raise SystemExit(1)
204
204
 
205
205
 
206
- def has_containerized_providers(config: dict) -> bool:
207
- """Return True if any declared provider runs in a Docker container."""
208
- from ..providers.registry import is_containerized
209
-
210
- return any(
211
- is_containerized(cfg.get("type", ""))
212
- for cfg in config.get("providers", {}).values()
213
- )
214
-
215
-
216
- def wait_for_provider_health(config: dict, timeout: int = 30) -> None:
217
- """Poll ``/health`` for each localhost containerized provider until ready.
218
-
219
- Only providers whose resolved URL host is ``localhost``/``127.0.0.1`` are
220
- waited on — remote providers are assumed to be managed elsewhere.
221
- """
222
- import time
223
- import urllib.parse
224
-
225
- from ..providers.registry import is_containerized
226
-
227
- providers = config.get("providers", {})
228
- deadline = time.time() + timeout
229
- for name, cfg in providers.items():
230
- if not is_containerized(cfg.get("type", "")):
231
- continue
232
- url = _resolve_provider_url(cfg, cfg.get("type", ""))
233
- host = urllib.parse.urlparse(url).hostname
234
- if host not in ("localhost", "127.0.0.1"):
235
- continue
236
- health = f"{url}/health"
237
- while time.time() < deadline:
238
- try:
239
- urllib.request.urlopen(health, timeout=2)
240
- logger.info(f"Provider '{name}' ready at {url}")
241
- break
242
- except (urllib.error.URLError, OSError):
243
- time.sleep(1)
244
- else:
245
- logger.warning(
246
- f"Provider '{name}' not ready at {url} after {timeout}s."
247
- )
248
-
249
-
250
206
  # ---------------------------------------------------------------------------
251
207
  # Divergence reports
252
208
  # ---------------------------------------------------------------------------
@@ -127,24 +127,34 @@ def init(
127
127
  generate_compose(pf_dir, docker_providers, outputs_dir)
128
128
  logger.info(f"Generated {compose_path}")
129
129
 
130
- # Attempt docker compose pull
130
+ # Attempt docker compose pull — stream progress live
131
131
  try:
132
- result = subprocess.run(
132
+ logger.info("Pulling Docker images (this may take a while)...")
133
+ process = subprocess.Popen(
133
134
  ["docker", "compose", "-f", compose_path, "pull"],
134
- capture_output=True,
135
+ stdout=subprocess.PIPE,
136
+ stderr=subprocess.STDOUT,
135
137
  text=True,
136
- timeout=120,
138
+ bufsize=1,
137
139
  )
138
- if result.returncode == 0:
140
+ assert process.stdout is not None
141
+ for line in process.stdout:
142
+ line = line.rstrip()
143
+ if line:
144
+ logger.info(line)
145
+ returncode = process.wait(timeout=600)
146
+ if returncode == 0:
139
147
  logger.info("Pulled Docker images.")
140
148
  else:
141
- logger.warning(f"docker compose pull failed: {result.stderr}")
149
+ logger.warning(
150
+ f"docker compose pull failed with exit code {returncode}."
151
+ )
142
152
  except FileNotFoundError:
143
153
  logger.warning(
144
154
  "Docker not found. Install Docker to use containerized providers."
145
155
  )
146
156
  except subprocess.TimeoutExpired:
147
- logger.warning("docker compose pull timed out after 120s.")
157
+ logger.warning("docker compose pull timed out after 600s.")
148
158
  else:
149
159
  logger.info("No containerized providers — skipping Docker setup.")
150
160
 
@@ -0,0 +1,107 @@
1
+ """``pf run`` — run a process simulation from a flowsheet JSON file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import typer
8
+ from loguru import logger
9
+
10
+ from ..flowsheet import Flowsheet
11
+ from ..eo import EOFlowsheet
12
+ from ..provenance import build_dynamic_x0, build_run_info
13
+ from ..result import plot_results, plot_timeseries, save_results_zarr
14
+ from ..state import StateManager
15
+ from .common import (
16
+ check_providers,
17
+ flowsheet_basename,
18
+ output_root,
19
+ require_existing_file,
20
+ validate_runtime_flowsheet,
21
+ )
22
+
23
+
24
+ def run(
25
+ flowsheet: str = typer.Argument(help="Path to the flowsheet JSON file"),
26
+ export_images: bool = typer.Option(
27
+ False,
28
+ "--export-images",
29
+ help="Generate PNG plots for simulation outputs",
30
+ ),
31
+ ) -> None:
32
+ """Run a process simulation from a flowsheet JSON file."""
33
+ require_existing_file(flowsheet)
34
+ config = validate_runtime_flowsheet(flowsheet)
35
+
36
+ # Check provider availability (assumes any containers are already running)
37
+ check_providers(config, flowsheet)
38
+
39
+ base_name = flowsheet_basename(flowsheet)
40
+ outputs_dir = output_root()
41
+
42
+ sim_cfg = config.get("simulation", {})
43
+ mode = sim_cfg.get("mode", "steady")
44
+ is_dynamic = mode == "dynamic"
45
+
46
+ if is_dynamic:
47
+ # Load .pfstate as t=0 if available
48
+ state_path = os.path.join(outputs_dir, f"{base_name}.pfstate")
49
+ sm = StateManager(state_path)
50
+ state = sm.load_state()
51
+ if state is not None:
52
+ stream_inits = sm.state_to_stream_dicts(state)
53
+ streams_cfg = config.get("streams", {})
54
+ for s_name, s_vals in stream_inits.items():
55
+ if s_name in streams_cfg:
56
+ streams_cfg[s_name].update(s_vals)
57
+ else:
58
+ logger.debug(
59
+ f"Stream '{s_name}' from snapshot not in current flowsheet — skipped."
60
+ )
61
+ logger.info("Using .pfstate converged state as dynamic t=0.")
62
+ else:
63
+ logger.debug("No .pfstate found — starting dynamic run from flowsheet defaults.")
64
+
65
+ fs = Flowsheet(config)
66
+ logger.info("=== Dynamic Results ===")
67
+ results = fs.run()
68
+
69
+ if hasattr(fs, "converged"):
70
+ if fs.converged:
71
+ logger.info("Dynamic simulation converged.")
72
+ else:
73
+ logger.warning("Dynamic simulation did NOT converge. Results may be unreliable.")
74
+
75
+ try:
76
+ x0, var_names = build_dynamic_x0(config)
77
+ except Exception as e:
78
+ logger.error(f"Failed to build initial state vector: {type(e).__name__}: {e}")
79
+ raise SystemExit(1)
80
+
81
+ run_info = build_run_info(config, x0=x0, var_names=var_names)
82
+ else:
83
+ # Pass None so EOFlowsheet resolves backend from config (with scipy default).
84
+ fs = EOFlowsheet(config, backend=None)
85
+ logger.info("=== Steady-State EO Results ===")
86
+ results = fs.run()
87
+
88
+ if hasattr(fs, "converged"):
89
+ if fs.converged:
90
+ logger.info("Steady-state simulation converged.")
91
+ else:
92
+ logger.warning("Steady-state simulation did NOT converge. Results may be unreliable.")
93
+
94
+ run_info = build_run_info(config, x0=fs.x0, var_names=fs.var_names)
95
+
96
+ os.makedirs(outputs_dir, exist_ok=True)
97
+ zarr_path = os.path.join(outputs_dir, f"{base_name}_results.zarr")
98
+ save_results_zarr(results, zarr_path, run_info=run_info)
99
+ logger.info(f"Results saved to {zarr_path}")
100
+
101
+ if export_images:
102
+ try:
103
+ plot_results(results, fname=f"{base_name}_results.png")
104
+ plot_timeseries(results, fname=f"{base_name}_timeseries.png")
105
+ logger.info(f"Plots saved: {base_name}_results.png, {base_name}_timeseries.png")
106
+ except Exception as e:
107
+ logger.warning(f"Failed to generate plots: {type(e).__name__}: {e}")
@@ -0,0 +1,52 @@
1
+ """Generate and manage docker-compose.yml for containerized providers."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+
6
+ from loguru import logger
7
+
8
+ COMPOSE_FILENAME = "docker-compose.yml"
9
+ COMPOSE_HEADER = (
10
+ "# Auto-generated by pf init — do not edit manually.\n"
11
+ "# Re-generate: pf init <flowsheet.json>\n"
12
+ )
13
+
14
+
15
+ def _compose_path(pf_dir: str) -> str:
16
+ return os.path.join(pf_dir, COMPOSE_FILENAME)
17
+
18
+
19
+ def generate_compose(
20
+ pf_dir: str,
21
+ containerized_providers: dict[str, dict],
22
+ outputs_dir: str = "outputs",
23
+ ) -> None:
24
+ """Generate docker-compose.yml for providers that need Docker.
25
+
26
+ Args:
27
+ pf_dir: Path to .processforge/ directory.
28
+ containerized_providers: Dict of provider_name -> {"url": ..., "docker_image": ..., "port": ...}.
29
+ outputs_dir: Host directory to mount as /data in containers.
30
+ """
31
+ if not containerized_providers:
32
+ return
33
+
34
+ lines = [COMPOSE_HEADER, "services:"]
35
+ for name, info in containerized_providers.items():
36
+ image = info["docker_image"]
37
+ port = info.get("port") or 9000
38
+ lines.append(f" {name}:")
39
+ lines.append(f" image: {image}")
40
+ # Bind directly to the host network so the provider is reachable at
41
+ # http://localhost:<port>. Docker bridge port-publishing is unreliable
42
+ # on some hosts (the published port is not reachable from the host),
43
+ # and host networking avoids that entirely.
44
+ lines.append(f" network_mode: host")
45
+ lines.append(f" volumes:")
46
+ lines.append(f' - "${{OPENMC_DATA_ROOT:-{outputs_dir}}}:/data"')
47
+ lines.append("")
48
+
49
+ os.makedirs(pf_dir, exist_ok=True)
50
+ path = _compose_path(pf_dir)
51
+ with open(path, "w", encoding="utf-8") as f:
52
+ f.write("\n".join(lines))