processforge 0.3.4__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.

Potentially problematic release.


This version of processforge might be problematic. Click here for more details.

Files changed (122) hide show
  1. {processforge-0.3.4/src/processforge.egg-info → processforge-0.3.5}/PKG-INFO +1 -1
  2. {processforge-0.3.4 → processforge-0.3.5}/pyproject.toml +1 -1
  3. processforge-0.3.5/src/processforge/cli/apply.py +209 -0
  4. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/cli/common.py +0 -44
  5. processforge-0.3.5/src/processforge/cli/run.py +107 -0
  6. processforge-0.3.5/src/processforge/compose.py +52 -0
  7. {processforge-0.3.4 → processforge-0.3.5/src/processforge.egg-info}/PKG-INFO +1 -1
  8. {processforge-0.3.4 → processforge-0.3.5}/tests/test_cli.py +4 -146
  9. processforge-0.3.4/src/processforge/cli/apply.py +0 -238
  10. processforge-0.3.4/src/processforge/cli/run.py +0 -137
  11. processforge-0.3.4/src/processforge/compose.py +0 -114
  12. {processforge-0.3.4 → processforge-0.3.5}/LICENSE +0 -0
  13. {processforge-0.3.4 → processforge-0.3.5}/MANIFEST.in +0 -0
  14. {processforge-0.3.4 → processforge-0.3.5}/README.md +0 -0
  15. {processforge-0.3.4 → processforge-0.3.5}/flowsheets/closed-loop-chain.json +0 -0
  16. {processforge-0.3.4 → processforge-0.3.5}/flowsheets/custom/crimson/MUWS_HB.json +0 -0
  17. {processforge-0.3.4 → processforge-0.3.5}/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -0
  18. {processforge-0.3.4 → processforge-0.3.5}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
  19. {processforge-0.3.4 → processforge-0.3.5}/flowsheets/hydraulic-chain.json +0 -0
  20. {processforge-0.3.4 → processforge-0.3.5}/flowsheets/openmc/msre_eigenvalue.json +0 -0
  21. {processforge-0.3.4 → processforge-0.3.5}/setup.cfg +0 -0
  22. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/__init__.py +0 -0
  23. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/__main__.py +0 -0
  24. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/_schema.py +0 -0
  25. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/analysis/__init__.py +0 -0
  26. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/analysis/dof.py +0 -0
  27. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/cli/__init__.py +0 -0
  28. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/cli/diagram.py +0 -0
  29. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/cli/display.py +0 -0
  30. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/cli/export_fmu.py +0 -0
  31. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/cli/export_modelica.py +0 -0
  32. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/cli/init.py +0 -0
  33. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/cli/plan.py +0 -0
  34. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/cli/validate.py +0 -0
  35. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/__init__.py +0 -0
  36. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/backends/__init__.py +0 -0
  37. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/backends/base.py +0 -0
  38. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/backends/casadi_backend.py +0 -0
  39. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  40. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/backends/scipy_backend.py +0 -0
  41. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/flowsheet.py +0 -0
  42. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/jacobian.py +0 -0
  43. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/mixin.py +0 -0
  44. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/solver.py +0 -0
  45. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/stream_var.py +0 -0
  46. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/units/__init__.py +0 -0
  47. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/units/flash_eo.py +0 -0
  48. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/units/heater_eo.py +0 -0
  49. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/units/pipes_eo.py +0 -0
  50. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/units/pump_eo.py +0 -0
  51. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/units/strainer_eo.py +0 -0
  52. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/eo/units/valve_eo.py +0 -0
  53. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/flowsheet.py +0 -0
  54. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/fmu/__init__.py +0 -0
  55. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/fmu/_fmi_vars.py +0 -0
  56. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/fmu/builder.py +0 -0
  57. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/fmu/slave_template.py +0 -0
  58. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/lock.py +0 -0
  59. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/modelica/__init__.py +0 -0
  60. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/modelica/mo_writer.py +0 -0
  61. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/modelica/omc_runner.py +0 -0
  62. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/modelica/transpiler.py +0 -0
  63. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/modelica/unit_equations.py +0 -0
  64. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/pcl/__init__.py +0 -0
  65. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/pcl/compiler.py +0 -0
  66. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/pcl/namespace.py +0 -0
  67. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/provenance.py +0 -0
  68. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/__init__.py +0 -0
  69. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/base.py +0 -0
  70. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/base_jacobian_mixin.py +0 -0
  71. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/cantera_jacobian.py +0 -0
  72. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/cantera_provider.py +0 -0
  73. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/container_client.py +0 -0
  74. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/coolprop_provider.py +0 -0
  75. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/festim_provider.py +0 -0
  76. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/jacobian_contributor.py +0 -0
  77. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/manager.py +0 -0
  78. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/modelica_jacobian.py +0 -0
  79. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/modelica_provider.py +0 -0
  80. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/openmc_provider.py +0 -0
  81. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/reference_state_registry.py +0 -0
  82. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/providers/registry.py +0 -0
  83. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/result.py +0 -0
  84. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/result_schema.py +0 -0
  85. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/schemas/__init__.py +0 -0
  86. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/schemas/flowsheet_schema.json +0 -0
  87. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/schemas/openmc/openmc_model.py +0 -0
  88. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/simulate.py +0 -0
  89. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/solver.py +0 -0
  90. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/state.py +0 -0
  91. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/thermo.py +0 -0
  92. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/types.py +0 -0
  93. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/__init__.py +0 -0
  94. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/base.py +0 -0
  95. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/cstr.py +0 -0
  96. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/flash.py +0 -0
  97. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/heater.py +0 -0
  98. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/pfr.py +0 -0
  99. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/pipes.py +0 -0
  100. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/pump.py +0 -0
  101. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/registry.py +0 -0
  102. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/solver_unit.py +0 -0
  103. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/strainer.py +0 -0
  104. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/tank.py +0 -0
  105. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/units/valve.py +0 -0
  106. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/utils/__init__.py +0 -0
  107. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/utils/flowsheet_diagram.py +0 -0
  108. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/utils/mermaid_diagram.py +0 -0
  109. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/utils/topology.py +0 -0
  110. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/utils/unit_consistency.py +0 -0
  111. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/utils/validate_flowsheet.py +0 -0
  112. {processforge-0.3.4 → processforge-0.3.5}/src/processforge/utils/validation.py +0 -0
  113. {processforge-0.3.4 → processforge-0.3.5}/src/processforge.egg-info/SOURCES.txt +0 -0
  114. {processforge-0.3.4 → processforge-0.3.5}/src/processforge.egg-info/dependency_links.txt +0 -0
  115. {processforge-0.3.4 → processforge-0.3.5}/src/processforge.egg-info/entry_points.txt +0 -0
  116. {processforge-0.3.4 → processforge-0.3.5}/src/processforge.egg-info/requires.txt +0 -0
  117. {processforge-0.3.4 → processforge-0.3.5}/src/processforge.egg-info/top_level.txt +0 -0
  118. {processforge-0.3.4 → processforge-0.3.5}/tests/test_festim_provider.py +0 -0
  119. {processforge-0.3.4 → processforge-0.3.5}/tests/test_init.py +0 -0
  120. {processforge-0.3.4 → processforge-0.3.5}/tests/test_providers.py +0 -0
  121. {processforge-0.3.4 → processforge-0.3.5}/tests/test_state.py +0 -0
  122. {processforge-0.3.4 → 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.4
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.4"
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
  # ---------------------------------------------------------------------------
@@ -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))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.3.4
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
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import os
6
- import subprocess
7
6
  from pathlib import Path
8
7
  from unittest.mock import MagicMock, patch
9
8
 
@@ -371,7 +370,7 @@ class TestCmdRun:
371
370
  mock_os.path.join = real_os.path.join
372
371
  return mock_os
373
372
 
374
- def test_starts_and_stops_containers(self, tmp_path, log_output):
373
+ def test_containerized_flowsheet_runs_without_lifecycle(self, tmp_path, log_output):
375
374
  import processforge.cli.run as run_mod
376
375
  from processforge.cli.run import run
377
376
 
@@ -385,9 +384,6 @@ class TestCmdRun:
385
384
  mock_fs.var_names = []
386
385
 
387
386
  with patch.object(run_mod, "os", self._patch_run_os(run_mod)), \
388
- patch.object(run_mod, "start_provider_containers", return_value=True) as mock_start, \
389
- patch.object(run_mod, "stop_provider_containers") as mock_stop, \
390
- patch.object(run_mod, "wait_for_provider_health"), \
391
387
  patch.object(run_mod, "validate_runtime_flowsheet",
392
388
  return_value=self._containerized_config(flowsheet)), \
393
389
  patch.object(run_mod, "check_providers"), \
@@ -395,36 +391,6 @@ class TestCmdRun:
395
391
  patch.object(run_mod, "save_results_zarr"):
396
392
  run(flowsheet=str(flowsheet), export_images=False)
397
393
 
398
- mock_start.assert_called_once()
399
- mock_stop.assert_called_once()
400
-
401
- def test_keep_containers_skips_stop(self, tmp_path):
402
- import processforge.cli.run as run_mod
403
- from processforge.cli.run import run
404
-
405
- flowsheet = tmp_path / "fs.json"
406
- flowsheet.write_text(json.dumps(self._containerized_config(flowsheet)["providers"]))
407
-
408
- mock_fs = MagicMock()
409
- mock_fs.run.return_value = {}
410
- mock_fs.converged = True
411
- mock_fs.x0 = []
412
- mock_fs.var_names = []
413
-
414
- with patch.object(run_mod, "os", self._patch_run_os(run_mod)), \
415
- patch.object(run_mod, "start_provider_containers", return_value=True) as mock_start, \
416
- patch.object(run_mod, "stop_provider_containers") as mock_stop, \
417
- patch.object(run_mod, "wait_for_provider_health"), \
418
- patch.object(run_mod, "validate_runtime_flowsheet",
419
- return_value=self._containerized_config(flowsheet)), \
420
- patch.object(run_mod, "check_providers"), \
421
- patch.object(run_mod, "EOFlowsheet", return_value=mock_fs), \
422
- patch.object(run_mod, "save_results_zarr"):
423
- run(flowsheet=str(flowsheet), export_images=False, keep_containers=True)
424
-
425
- mock_start.assert_called_once()
426
- mock_stop.assert_not_called()
427
-
428
394
  def test_coolprop_flowsheet_no_lifecycle(self, tmp_path):
429
395
  import processforge.cli.run as run_mod
430
396
  from processforge.cli.run import run
@@ -441,8 +407,6 @@ class TestCmdRun:
441
407
  # A stale docker-compose.yml is present, but the flowsheet only uses
442
408
  # pip providers — Docker must never be touched.
443
409
  with patch.object(run_mod, "os", self._patch_run_os(run_mod)), \
444
- patch.object(run_mod, "start_provider_containers") as mock_start, \
445
- patch.object(run_mod, "stop_provider_containers") as mock_stop, \
446
410
  patch.object(run_mod, "validate_runtime_flowsheet",
447
411
  return_value={"providers": {"coolprop": {"type": "coolprop"}},
448
412
  "streams": {}, "units": {},
@@ -453,9 +417,6 @@ class TestCmdRun:
453
417
  patch.object(run_mod, "save_results_zarr"):
454
418
  run(flowsheet=str(flowsheet), export_images=False)
455
419
 
456
- mock_start.assert_not_called()
457
- mock_stop.assert_not_called()
458
-
459
420
  def test_no_compose_no_lifecycle(self, tmp_path):
460
421
  import processforge.cli.run as run_mod
461
422
  from processforge.cli.run import run
@@ -469,10 +430,9 @@ class TestCmdRun:
469
430
  mock_fs.x0 = []
470
431
  mock_fs.var_names = []
471
432
 
472
- # Containerized flowsheet but no compose file -> warn and skip.
433
+ # Containerized flowsheet but no compose file run still proceeds
434
+ # (containers are assumed already running).
473
435
  with patch.object(run_mod, "os") as mock_os, \
474
- patch.object(run_mod, "start_provider_containers") as mock_start, \
475
- patch.object(run_mod, "stop_provider_containers") as mock_stop, \
476
436
  patch.object(run_mod, "validate_runtime_flowsheet",
477
437
  return_value=self._containerized_config(flowsheet)), \
478
438
  patch.object(run_mod, "check_providers"), \
@@ -481,66 +441,6 @@ class TestCmdRun:
481
441
  mock_os.path.exists.return_value = False # no compose file
482
442
  run(flowsheet=str(flowsheet), export_images=False)
483
443
 
484
- mock_start.assert_not_called()
485
- mock_stop.assert_not_called()
486
-
487
-
488
- class TestComposeLifecycle:
489
- def _write_compose(self, tmp_path):
490
- pf = tmp_path / ".processforge"
491
- pf.mkdir()
492
- (pf / "docker-compose.yml").write_text("services: {}\n")
493
- return str(pf)
494
-
495
- def test_start_success(self, tmp_path, log_output):
496
- from processforge.compose import start_provider_containers
497
-
498
- pf = self._write_compose(tmp_path)
499
- with patch("processforge.compose.subprocess.run") as mock_run:
500
- mock_run.return_value = subprocess.CompletedProcess([], 0, "", "")
501
- assert start_provider_containers(pf) is True
502
- mock_run.assert_called_once()
503
- assert any("Started provider container" in m for m in log_output)
504
-
505
- def test_start_no_compose(self, tmp_path):
506
- from processforge.compose import start_provider_containers
507
-
508
- assert start_provider_containers(str(tmp_path / "nope")) is False
509
-
510
- def test_start_docker_missing(self, tmp_path, log_output):
511
- from processforge.compose import start_provider_containers
512
-
513
- pf = self._write_compose(tmp_path)
514
- with patch("processforge.compose.subprocess.run", side_effect=FileNotFoundError):
515
- assert start_provider_containers(pf) is False
516
- assert any("Docker not found" in m for m in log_output)
517
-
518
- def test_start_compose_failure(self, tmp_path, log_output):
519
- from processforge.compose import start_provider_containers
520
-
521
- pf = self._write_compose(tmp_path)
522
- with patch("processforge.compose.subprocess.run") as mock_run:
523
- mock_run.return_value = subprocess.CompletedProcess([], 1, "", "boom")
524
- assert start_provider_containers(pf) is False
525
- assert any("docker compose up failed" in m for m in log_output)
526
-
527
- def test_stop_success(self, tmp_path, log_output):
528
- from processforge.compose import stop_provider_containers
529
-
530
- pf = self._write_compose(tmp_path)
531
- with patch("processforge.compose.subprocess.run") as mock_run:
532
- mock_run.return_value = subprocess.CompletedProcess([], 0, "", "")
533
- stop_provider_containers(pf)
534
- mock_run.assert_called_once()
535
- assert any("Stopped provider container" in m for m in log_output)
536
-
537
- def test_stop_no_compose(self, tmp_path):
538
- from processforge.compose import stop_provider_containers
539
-
540
- with patch("processforge.compose.subprocess.run") as mock_run:
541
- stop_provider_containers(str(tmp_path / "nope"))
542
- mock_run.assert_not_called()
543
-
544
444
 
545
445
  class TestCmdApply:
546
446
  def _containerized_config(self, flowsheet_path):
@@ -560,7 +460,7 @@ class TestCmdApply:
560
460
  mock_os.path.join = real_os.path.join
561
461
  return mock_os
562
462
 
563
- def test_starts_and_stops_containers(self, tmp_path, log_output):
463
+ def test_containerized_flowsheet_runs_without_lifecycle(self, tmp_path, log_output):
564
464
  import processforge.cli.apply as apply_mod
565
465
  from processforge.cli.apply import apply
566
466
 
@@ -576,9 +476,6 @@ class TestCmdApply:
576
476
  mock_fs.solver_stats = {"final_norm": 0.0}
577
477
 
578
478
  with patch.object(apply_mod, "os", self._patch_apply_os(apply_mod)), \
579
- patch.object(apply_mod, "start_provider_containers", return_value=True) as mock_start, \
580
- patch.object(apply_mod, "stop_provider_containers") as mock_stop, \
581
- patch.object(apply_mod, "wait_for_provider_health"), \
582
479
  patch.object(apply_mod, "validate_runtime_flowsheet",
583
480
  return_value=self._containerized_config(flowsheet)), \
584
481
  patch.object(apply_mod, "check_providers"), \
@@ -587,39 +484,6 @@ class TestCmdApply:
587
484
  patch.object(apply_mod, "save_results_zarr"):
588
485
  apply(flowsheet=str(flowsheet))
589
486
 
590
- mock_start.assert_called_once()
591
- mock_stop.assert_called_once()
592
-
593
- def test_keep_containers_skips_stop(self, tmp_path):
594
- import processforge.cli.apply as apply_mod
595
- from processforge.cli.apply import apply
596
-
597
- flowsheet = tmp_path / "fs.json"
598
- flowsheet.write_text(json.dumps(self._containerized_config(flowsheet)["providers"]))
599
-
600
- mock_fs = MagicMock()
601
- mock_fs.run.return_value = {}
602
- mock_fs.converged = True
603
- mock_fs.x0 = []
604
- mock_fs.x_converged = []
605
- mock_fs.var_names = []
606
- mock_fs.solver_stats = {"final_norm": 0.0}
607
-
608
- with patch.object(apply_mod, "os", self._patch_apply_os(apply_mod)), \
609
- patch.object(apply_mod, "start_provider_containers", return_value=True) as mock_start, \
610
- patch.object(apply_mod, "stop_provider_containers") as mock_stop, \
611
- patch.object(apply_mod, "wait_for_provider_health"), \
612
- patch.object(apply_mod, "validate_runtime_flowsheet",
613
- return_value=self._containerized_config(flowsheet)), \
614
- patch.object(apply_mod, "check_providers"), \
615
- patch.object(apply_mod, "EOFlowsheet", return_value=mock_fs), \
616
- patch.object(apply_mod, "save_snapshot", return_value="snap-1"), \
617
- patch.object(apply_mod, "save_results_zarr"):
618
- apply(flowsheet=str(flowsheet), keep_containers=True)
619
-
620
- mock_start.assert_called_once()
621
- mock_stop.assert_not_called()
622
-
623
487
  def test_coolprop_flowsheet_no_lifecycle(self, tmp_path):
624
488
  import processforge.cli.apply as apply_mod
625
489
  from processforge.cli.apply import apply
@@ -638,9 +502,6 @@ class TestCmdApply:
638
502
  # A stale docker-compose.yml is present, but the flowsheet only uses
639
503
  # pip providers — Docker must never be touched.
640
504
  with patch.object(apply_mod, "os", self._patch_apply_os(apply_mod)), \
641
- patch.object(apply_mod, "start_provider_containers") as mock_start, \
642
- patch.object(apply_mod, "stop_provider_containers") as mock_stop, \
643
- patch.object(apply_mod, "wait_for_provider_health"), \
644
505
  patch.object(apply_mod, "validate_runtime_flowsheet",
645
506
  return_value={"providers": {"coolprop": {"type": "coolprop"}},
646
507
  "streams": {}, "units": {},
@@ -652,9 +513,6 @@ class TestCmdApply:
652
513
  patch.object(apply_mod, "save_results_zarr"):
653
514
  apply(flowsheet=str(flowsheet))
654
515
 
655
- mock_start.assert_not_called()
656
- mock_stop.assert_not_called()
657
-
658
516
 
659
517
  # ---------------------------------------------------------------------------
660
518
  # cli/diagram.py