processforge 0.3.21__tar.gz → 0.3.23__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. {processforge-0.3.21/src/processforge.egg-info → processforge-0.3.23}/PKG-INFO +2 -1
  2. {processforge-0.3.21 → processforge-0.3.23}/flowsheets/hydraulic-chain.json +1 -1
  3. {processforge-0.3.21 → processforge-0.3.23}/pyproject.toml +2 -1
  4. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/__init__.py +1 -1
  5. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/cli/apply.py +3 -0
  6. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/cli/common.py +105 -34
  7. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/cli/run.py +38 -0
  8. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/cli/runs.py +13 -21
  9. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/output_collector.py +17 -0
  10. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/container_client.py +13 -0
  11. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/coolprop_provider.py +12 -3
  12. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/state.py +61 -27
  13. {processforge-0.3.21 → processforge-0.3.23/src/processforge.egg-info}/PKG-INFO +2 -1
  14. {processforge-0.3.21 → processforge-0.3.23}/src/processforge.egg-info/requires.txt +1 -0
  15. {processforge-0.3.21 → processforge-0.3.23}/tests/test_cli.py +53 -0
  16. {processforge-0.3.21 → processforge-0.3.23}/tests/test_runs_command.py +24 -1
  17. {processforge-0.3.21 → processforge-0.3.23}/tests/test_state.py +95 -0
  18. {processforge-0.3.21 → processforge-0.3.23}/LICENSE +0 -0
  19. {processforge-0.3.21 → processforge-0.3.23}/MANIFEST.in +0 -0
  20. {processforge-0.3.21 → processforge-0.3.23}/README.md +0 -0
  21. {processforge-0.3.21 → processforge-0.3.23}/flowsheets/closed-loop-chain.json +0 -0
  22. {processforge-0.3.21 → processforge-0.3.23}/flowsheets/coupled/msre_coupled.json +0 -0
  23. {processforge-0.3.21 → processforge-0.3.23}/flowsheets/custom/crimson/MUWS_HB.json +0 -0
  24. {processforge-0.3.21 → processforge-0.3.23}/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -0
  25. {processforge-0.3.21 → processforge-0.3.23}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
  26. {processforge-0.3.21 → processforge-0.3.23}/flowsheets/festim/tds_tungsten.json +0 -0
  27. {processforge-0.3.21 → processforge-0.3.23}/flowsheets/openmc/msre_eigenvalue.json +0 -0
  28. {processforge-0.3.21 → processforge-0.3.23}/setup.cfg +0 -0
  29. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/__main__.py +0 -0
  30. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/_schema.py +0 -0
  31. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/analysis/__init__.py +0 -0
  32. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/analysis/dof.py +0 -0
  33. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/api/__init__.py +0 -0
  34. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/api/serve.py +0 -0
  35. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/cli/__init__.py +0 -0
  36. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/cli/diagram.py +0 -0
  37. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/cli/display.py +0 -0
  38. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/cli/export_fmu.py +0 -0
  39. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/cli/export_modelica.py +0 -0
  40. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/cli/init.py +0 -0
  41. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/cli/plan.py +0 -0
  42. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/cli/validate.py +0 -0
  43. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/compose.py +0 -0
  44. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/coupling.py +0 -0
  45. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/__init__.py +0 -0
  46. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/backends/__init__.py +0 -0
  47. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/backends/base.py +0 -0
  48. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/backends/casadi_backend.py +0 -0
  49. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  50. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/backends/scipy_backend.py +0 -0
  51. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/flowsheet.py +0 -0
  52. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/jacobian.py +0 -0
  53. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/mixin.py +0 -0
  54. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/solver.py +0 -0
  55. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/stream_var.py +0 -0
  56. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/units/__init__.py +0 -0
  57. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/units/flash_eo.py +0 -0
  58. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/units/heater_eo.py +0 -0
  59. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/units/pipes_eo.py +0 -0
  60. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/units/pump_eo.py +0 -0
  61. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/units/strainer_eo.py +0 -0
  62. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/eo/units/valve_eo.py +0 -0
  63. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/flowsheet.py +0 -0
  64. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/fmu/__init__.py +0 -0
  65. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/fmu/_fmi_vars.py +0 -0
  66. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/fmu/builder.py +0 -0
  67. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/fmu/slave_template.py +0 -0
  68. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/lock.py +0 -0
  69. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/modelica/__init__.py +0 -0
  70. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/modelica/mo_writer.py +0 -0
  71. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/modelica/omc_runner.py +0 -0
  72. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/modelica/transpiler.py +0 -0
  73. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/modelica/unit_equations.py +0 -0
  74. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/pcl/__init__.py +0 -0
  75. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/pcl/compiler.py +0 -0
  76. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/pcl/namespace.py +0 -0
  77. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/persistence/__init__.py +0 -0
  78. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/persistence/archive.py +0 -0
  79. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/persistence/artifact_store.py +0 -0
  80. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/provenance.py +0 -0
  81. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/__init__.py +0 -0
  82. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/base.py +0 -0
  83. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/base_jacobian_mixin.py +0 -0
  84. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/cantera_jacobian.py +0 -0
  85. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/cantera_provider.py +0 -0
  86. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/errors.py +0 -0
  87. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/festim_provider.py +0 -0
  88. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/jacobian_contributor.py +0 -0
  89. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/manager.py +0 -0
  90. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/modelica_jacobian.py +0 -0
  91. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/modelica_provider.py +0 -0
  92. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/openmc_provider.py +0 -0
  93. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/reference_state_registry.py +0 -0
  94. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/providers/registry.py +0 -0
  95. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/quantity.py +0 -0
  96. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/result.py +0 -0
  97. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/result_schema.py +0 -0
  98. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/schemas/__init__.py +0 -0
  99. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/schemas/festim/festim_model.py +0 -0
  100. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/schemas/flowsheet_schema.json +0 -0
  101. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/schemas/openmc/openmc_model.py +0 -0
  102. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/simulate.py +0 -0
  103. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/solver.py +0 -0
  104. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/thermo.py +0 -0
  105. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/types.py +0 -0
  106. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/__init__.py +0 -0
  107. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/base.py +0 -0
  108. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/cstr.py +0 -0
  109. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/flash.py +0 -0
  110. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/heater.py +0 -0
  111. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/pfr.py +0 -0
  112. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/pipes.py +0 -0
  113. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/pump.py +0 -0
  114. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/registry.py +0 -0
  115. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/solver_unit.py +0 -0
  116. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/strainer.py +0 -0
  117. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/tank.py +0 -0
  118. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/units/valve.py +0 -0
  119. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/utils/__init__.py +0 -0
  120. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/utils/flowsheet_diagram.py +0 -0
  121. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/utils/mermaid_diagram.py +0 -0
  122. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/utils/s3_upload.py +0 -0
  123. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/utils/topology.py +0 -0
  124. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/utils/unit_consistency.py +0 -0
  125. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/utils/validate_flowsheet.py +0 -0
  126. {processforge-0.3.21 → processforge-0.3.23}/src/processforge/utils/validation.py +0 -0
  127. {processforge-0.3.21 → processforge-0.3.23}/src/processforge.egg-info/SOURCES.txt +0 -0
  128. {processforge-0.3.21 → processforge-0.3.23}/src/processforge.egg-info/dependency_links.txt +0 -0
  129. {processforge-0.3.21 → processforge-0.3.23}/src/processforge.egg-info/entry_points.txt +0 -0
  130. {processforge-0.3.21 → processforge-0.3.23}/src/processforge.egg-info/top_level.txt +0 -0
  131. {processforge-0.3.21 → processforge-0.3.23}/tests/test_container_client.py +0 -0
  132. {processforge-0.3.21 → processforge-0.3.23}/tests/test_coupling.py +0 -0
  133. {processforge-0.3.21 → processforge-0.3.23}/tests/test_festim_provider.py +0 -0
  134. {processforge-0.3.21 → processforge-0.3.23}/tests/test_festim_schema.py +0 -0
  135. {processforge-0.3.21 → processforge-0.3.23}/tests/test_init.py +0 -0
  136. {processforge-0.3.21 → processforge-0.3.23}/tests/test_openmc_provider.py +0 -0
  137. {processforge-0.3.21 → processforge-0.3.23}/tests/test_provider_errors.py +0 -0
  138. {processforge-0.3.21 → processforge-0.3.23}/tests/test_providers.py +0 -0
  139. {processforge-0.3.21 → processforge-0.3.23}/tests/test_result_zarr.py +0 -0
  140. {processforge-0.3.21 → processforge-0.3.23}/tests/test_s3_upload.py +0 -0
  141. {processforge-0.3.21 → processforge-0.3.23}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.3.21
3
+ Version: 0.3.23
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
@@ -31,6 +31,7 @@ Requires-Dist: zarr>=3.0
31
31
  Requires-Dist: pint<1.0,>=0.24
32
32
  Requires-Dist: coolprop>=7.2.0
33
33
  Requires-Dist: typer>=0.12
34
+ Requires-Dist: arrow>=1.3
34
35
  Provides-Extra: api
35
36
  Requires-Dist: fastapi>=0.110; extra == "api"
36
37
  Requires-Dist: uvicorn>=0.29; extra == "api"
@@ -105,7 +105,7 @@
105
105
  "simulation": {
106
106
  "mode": "steady",
107
107
  "t0": 0.0,
108
- "tf": 20.0,
108
+ "tf": 25.0,
109
109
  "dt": 1.0
110
110
  }
111
111
  }
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "processforge"
7
- version = "0.3.21"
7
+ version = "0.3.23"
8
8
  description = "A Python-based process simulation framework for chemical engineering applications."
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -37,6 +37,7 @@ dependencies = [
37
37
  "pint>=0.24,<1.0",
38
38
  "coolprop>=7.2.0",
39
39
  "typer>=0.12",
40
+ "arrow>=1.3",
40
41
  ]
41
42
 
42
43
  [project.optional-dependencies]
@@ -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.21"
33
+ __version__ = "0.3.23"
34
34
 
35
35
  __all__ = [
36
36
  "Flowsheet",
@@ -133,11 +133,14 @@ def apply(
133
133
  return
134
134
  stream_drifts = [d for d in drifted if d.startswith("streams.")]
135
135
  unit_drifts = [d for d in drifted if d.startswith("units.")]
136
+ other_drifts = [d for d in drifted if not d.startswith(("streams.", "units."))]
136
137
  logger.warning("Drift detected:")
137
138
  if stream_drifts:
138
139
  logger.warning(f" Stream drift : {stream_drifts}")
139
140
  if unit_drifts:
140
141
  logger.warning(f" Unit drift : {unit_drifts}")
142
+ if other_drifts:
143
+ logger.warning(f" Config drift : {other_drifts}")
141
144
 
142
145
  # Build flowsheet; attach saved state for warm-start unless topology changed
143
146
  fs = EOFlowsheet(config, backend=backend)
@@ -145,6 +145,13 @@ def load_state_manager(outputs_dir: str, base_name: str) -> tuple[ProcessStateAr
145
145
  # Provider checking
146
146
  # ---------------------------------------------------------------------------
147
147
 
148
+ # Retry/poll settings for containerized provider health checks. A container
149
+ # spun up by ``pf init`` may take a few seconds to become ready; poll rather
150
+ # than failing immediately so ``pf run`` waits for readiness with visible text.
151
+ HEALTH_MAX_ATTEMPTS = 6
152
+ HEALTH_RETRY_DELAY = 5 # seconds between attempts
153
+
154
+
148
155
  def is_local_provider_url(url: str | None) -> bool:
149
156
  """Return True if *url* points at a locally-managed provider.
150
157
 
@@ -184,6 +191,85 @@ def _ping_provider_health(url: str, timeout: int = 5) -> tuple[bool, "dict | str
184
191
  return False, str(exc)
185
192
 
186
193
 
194
+ def _check_container_provider(
195
+ name: str,
196
+ ptype: str,
197
+ url: str,
198
+ flowsheet_path: str,
199
+ *,
200
+ fail_fast: bool,
201
+ errors: list[str],
202
+ ) -> None:
203
+ """Probe a containerized provider's ``/health`` endpoint with a retry/poll.
204
+
205
+ Polls up to ``HEALTH_MAX_ATTEMPTS`` times (``HEALTH_RETRY_DELAY`` apart) so a
206
+ container that is still booting after ``pf init`` gets a chance to become
207
+ ready before we give up. Logs a visible ``[OK]``/``[ERR]`` line (matching the
208
+ ``pf plan`` convention) so ``pf run`` never silently waits.
209
+ """
210
+ import time
211
+
212
+ ok, info = False, "not probed"
213
+ for attempt in range(1, HEALTH_MAX_ATTEMPTS + 1):
214
+ ok, info = _ping_provider_health(url, timeout=5)
215
+ if ok:
216
+ break
217
+ if attempt < HEALTH_MAX_ATTEMPTS:
218
+ logger.info(
219
+ f" Waiting for '{name}' container to become healthy at {url}… "
220
+ f"(attempt {attempt}/{HEALTH_MAX_ATTEMPTS})"
221
+ )
222
+ time.sleep(HEALTH_RETRY_DELAY)
223
+
224
+ if ok:
225
+ payload = info if isinstance(info, dict) else {}
226
+ status = payload.get("status", "?")
227
+ provider_type = payload.get("provider_type", "?")
228
+ logger.info(
229
+ f" [OK] {name} [{ptype}] {url} — status={status} provider_type={provider_type}"
230
+ )
231
+ return
232
+
233
+ msg = (
234
+ f" [ERR] {name} [{ptype}] {url} — unreachable after "
235
+ f"{HEALTH_MAX_ATTEMPTS} attempt(s): {info}. "
236
+ f"Run: pf init {flowsheet_path}"
237
+ )
238
+ logger.error(msg)
239
+ if fail_fast:
240
+ raise SystemExit(1)
241
+ errors.append(f"Provider '{name}' unreachable at {url}: {info}")
242
+
243
+
244
+ def _check_pip_provider(
245
+ name: str,
246
+ ptype: str,
247
+ flowsheet_path: str,
248
+ *,
249
+ fail_fast: bool,
250
+ errors: list[str],
251
+ ) -> None:
252
+ """Verify a pip-installable provider's module is importable."""
253
+ from ..providers.registry import _PROVIDER_CATALOG
254
+
255
+ catalog = _PROVIDER_CATALOG.get(ptype, {})
256
+ module = catalog.get("module", "")
257
+ try:
258
+ importlib.util.find_spec(module)
259
+ logger.info(f" [OK] {name} [{ptype}] (pip — importable)")
260
+ except (ModuleNotFoundError, ValueError):
261
+ dep = catalog.get("optional_dep")
262
+ hint = f"pip install 'processforge[{dep}]'" if dep else "built-in"
263
+ msg = (
264
+ f" [WARN] {name} [{ptype}] — not installed. "
265
+ f"Run: pf init {flowsheet_path} (install with: {hint})"
266
+ )
267
+ logger.warning(msg)
268
+ if fail_fast:
269
+ raise SystemExit(1)
270
+ errors.append(f"Provider '{name}' not installed ({hint})")
271
+
272
+
187
273
  def check_providers(
188
274
  config: dict,
189
275
  flowsheet_path: str,
@@ -193,56 +279,41 @@ def check_providers(
193
279
  ) -> None:
194
280
  """Verify all declared providers are reachable or importable.
195
281
 
282
+ For containerized providers this pings the container's ``/health`` endpoint
283
+ (with a short retry/poll so a still-booting container can become ready) and
284
+ logs a visible ``[OK]``/``[ERR]`` line — so ``pf run`` shows a clear health
285
+ check instead of silently waiting. Pip-installable providers are checked for
286
+ importability and reported the same way.
287
+
196
288
  Parameters
197
289
  ----------
198
290
  fail_fast:
199
291
  If ``True`` (default), raise ``SystemExit`` on the first failure.
200
292
  If ``False``, accumulate errors and raise once at the end.
201
293
  verbose:
202
- If ``True``, log per-provider success messages.
294
+ Accepted for compatibility; the health check is always logged.
203
295
  """
204
- from ..providers.registry import is_containerized, _PROVIDER_CATALOG
296
+ from ..providers.registry import is_containerized
205
297
 
206
298
  providers = config.get("providers", {})
207
299
  errors: list[str] = []
208
300
 
301
+ if providers:
302
+ logger.info("=== Provider / Container Health ===")
303
+
209
304
  for name, cfg in providers.items():
210
305
  ptype = cfg.get("type", "")
211
306
  if is_containerized(ptype):
212
307
  url = _resolve_provider_url(cfg, ptype)
213
- try:
214
- urllib.request.urlopen(f"{url}/health", timeout=5)
215
- if verbose:
216
- logger.info(f"Provider '{name}' — reachable at {url}")
217
- except (urllib.error.URLError, OSError, TimeoutError) as exc:
218
- msg = (
219
- f"Provider '{name}' unreachable at {url}. "
220
- f"Run: pf init {flowsheet_path}"
221
- )
222
- if fail_fast:
223
- logger.error(msg)
224
- raise SystemExit(1)
225
- logger.error(msg)
226
- errors.append(msg)
308
+ _check_container_provider(
309
+ name, ptype, url, flowsheet_path,
310
+ fail_fast=fail_fast, errors=errors,
311
+ )
227
312
  else:
228
- catalog = _PROVIDER_CATALOG.get(ptype, {})
229
- module = catalog.get("module", "")
230
- try:
231
- importlib.util.find_spec(module)
232
- if verbose:
233
- logger.info(f"Provider '{name}' — importable (pip)")
234
- except (ModuleNotFoundError, ValueError):
235
- dep = catalog.get("optional_dep")
236
- hint = f"pip install 'processforge[{dep}]'" if dep else "built-in"
237
- msg = (
238
- f"Provider '{name}' not installed. "
239
- f"Run: pf init {flowsheet_path} (install with: {hint})"
240
- )
241
- if fail_fast:
242
- logger.error(msg)
243
- raise SystemExit(1)
244
- logger.error(msg)
245
- errors.append(msg)
313
+ _check_pip_provider(
314
+ name, ptype, flowsheet_path,
315
+ fail_fast=fail_fast, errors=errors,
316
+ )
246
317
 
247
318
  if errors:
248
319
  raise SystemExit(1)
@@ -19,6 +19,7 @@ from .common import (
19
19
  output_root,
20
20
  require_existing_file,
21
21
  validate_runtime_flowsheet,
22
+ _resolve_provider_url,
22
23
  )
23
24
 
24
25
 
@@ -38,6 +39,39 @@ def _run_id() -> str:
38
39
  return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "_" + os.urandom(3).hex()
39
40
 
40
41
 
42
+ def _log_active_providers(config: dict) -> None:
43
+ """Log a one-line summary of the provider(s) a run will dispatch to.
44
+
45
+ Highlights containerized providers (which do their real work inside a Docker
46
+ container and can take a while) so the user sees what is running instead of
47
+ a silent wait. Pip-installable providers are mentioned for completeness.
48
+ """
49
+ from ..providers.registry import is_containerized
50
+
51
+ providers = config.get("providers", {})
52
+ if not providers:
53
+ return
54
+
55
+ containers = []
56
+ pips = []
57
+ for name, cfg in providers.items():
58
+ ptype = cfg.get("type", "")
59
+ if is_containerized(ptype):
60
+ url = _resolve_provider_url(cfg, ptype)
61
+ containers.append(f"{name} [{ptype}] @ {url}")
62
+ else:
63
+ pips.append(f"{name} [{ptype}]")
64
+
65
+ if containers:
66
+ logger.info("=== Active providers (containerized) ===")
67
+ for entry in containers:
68
+ logger.info(f" → {entry}")
69
+ if pips:
70
+ logger.info("=== Active providers (local) ===")
71
+ for entry in pips:
72
+ logger.info(f" → {entry}")
73
+
74
+
41
75
  def run(
42
76
  flowsheet: str = typer.Argument(help="Path to the flowsheet JSON file"),
43
77
  export_images: bool = typer.Option(
@@ -53,6 +87,10 @@ def run(
53
87
  # Check provider availability (assumes any containers are already running)
54
88
  check_providers(config, flowsheet)
55
89
 
90
+ # Surface which containerized provider(s) this run will dispatch to, so the
91
+ # command line isn't silent while the container does the heavy compute.
92
+ _log_active_providers(config)
93
+
56
94
  base_name = flowsheet_basename(flowsheet)
57
95
  outputs_dir = output_root()
58
96
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
 
8
+ import arrow
8
9
  import typer
9
10
  from loguru import logger
10
11
 
@@ -12,20 +13,16 @@ from .common import flowsheet_basename, output_root
12
13
  from ..persistence.archive import ProcessStateArchive
13
14
 
14
15
 
15
- def _summary_for(manifest) -> str:
16
- """One-line summary of a run's primary unit fields (e.g. keff)."""
17
- parts = []
18
- for unit_name, out in manifest.units.items():
19
- for f in out.fields:
20
- val = f.quantity.value
21
- unit = f.quantity.unit or ""
22
- parts.append(f"{unit_name}.{f.name}={val}{(' ' + unit) if unit else ''}")
23
- return " ".join(parts) if parts else "-"
16
+ def _humanize_timestamp(ts: str) -> str:
17
+ """Render a stored timestamp as a human-readable relative string.
24
18
 
25
-
26
- def _zarr_status(archive_path: str, run_id: str) -> str:
27
- zarr_path = os.path.join(archive_path, "results", run_id, "results.zarr")
28
- return "✓" if os.path.exists(zarr_path) else "✗"
19
+ Falls back to the raw string for values that aren't parseable ISO-8601
20
+ (e.g. ``20260101T000000Z`` used in some test fixtures).
21
+ """
22
+ try:
23
+ return arrow.get(ts).humanize()
24
+ except (arrow.parser.ParserError, ValueError, TypeError):
25
+ return ts
29
26
 
30
27
 
31
28
  def runs(
@@ -73,10 +70,7 @@ def runs(
73
70
  typer.echo(f"No runs found for '{flowsheet}'.")
74
71
  return
75
72
 
76
- header = (
77
- f"{'RUN ID':<28} {'TIMESTAMP':<22} {'MODE':<8} "
78
- f"{'ZARR':<5} {'LATEST':<6} SUMMARY"
79
- )
73
+ header = f"{'RUN ID':<28} {'TIMESTAMP':<22} LATEST"
80
74
  typer.echo(header)
81
75
  typer.echo("-" * len(header))
82
76
  for rid in run_ids:
@@ -85,12 +79,10 @@ def runs(
85
79
  continue
86
80
  marker = "*" if rid == latest_id else " "
87
81
  typer.echo(
88
- f"{rid:<28} {manifest.timestamp:<22} {manifest.mode:<8} "
89
- f"{_zarr_status(archive_path, rid):<5} {marker:<6} "
90
- f"{_summary_for(manifest)}"
82
+ f"{rid:<28} {_humanize_timestamp(manifest.timestamp):<22} {marker}"
91
83
  )
92
84
  typer.echo("")
93
85
  typer.echo(
94
- "Zarr: ✓ present on disk, ✗ deleted/missing. '*' marks the latest run. "
86
+ "'*' marks the latest run. "
95
87
  "'pf runs <flowsheet> <run_id>' prints the full manifest."
96
88
  )
@@ -14,8 +14,10 @@ canonical, unit-aware record persisted by :class:`ProcessStateArchive`:
14
14
  """
15
15
  from __future__ import annotations
16
16
 
17
+ from datetime import datetime, timezone
17
18
  from typing import Optional
18
19
 
20
+ import arrow
19
21
  from loguru import logger
20
22
 
21
23
  from .types import (
@@ -75,6 +77,20 @@ def collect_stream_outputs(provider, stream_results: dict) -> dict[str, StreamOu
75
77
  return out
76
78
 
77
79
 
80
+ def _timestamp_from_run_id(run_id: str) -> str:
81
+ """Derive an ISO-8601 timestamp from a run_id's compact UTC prefix.
82
+
83
+ Run ids are ``<YYYYMMDDTHHMMSSZ>_<suffix>`` (see ``pf run`` / ``pf apply``),
84
+ so the start time is recoverable from the id itself. Falls back to the
85
+ current UTC time for ids that don't follow that convention.
86
+ """
87
+ prefix = run_id.split("_", 1)[0]
88
+ try:
89
+ return arrow.get(prefix).isoformat()
90
+ except (arrow.parser.ParserError, ValueError):
91
+ return datetime.now(timezone.utc).isoformat()
92
+
93
+
78
94
  def build_run_manifest(
79
95
  *,
80
96
  run_id: str,
@@ -91,6 +107,7 @@ def build_run_manifest(
91
107
  run_id=run_id,
92
108
  mode=mode,
93
109
  flowsheet=flowsheet_name,
110
+ timestamp=_timestamp_from_run_id(run_id),
94
111
  units=dict(engine_outputs),
95
112
  streams=streams,
96
113
  provenance=provenance or {},
@@ -177,6 +177,15 @@ class ContainerProviderClient(AbstractProvider):
177
177
  else {"type": unit_config.provider or self._ptype, "url": self._url}
178
178
  )
179
179
 
180
+ # Fall back to context set via set_run_context() (the CLI propagates the
181
+ # shared run_id/flowsheet_hash there). SolverUnit calls run_simulation()
182
+ # without these arguments, so without the fallback the container receives
183
+ # null and artifacts get uploaded under the wrong key.
184
+ if run_id is None:
185
+ run_id = self._run_id or None
186
+ if flowsheet_hash is None:
187
+ flowsheet_hash = self._flowsheet_hash or None
188
+
180
189
  body = {
181
190
  "unit_config": self._serialize_unit_config(unit_config),
182
191
  "materials": self._serialize_materials(self._materials),
@@ -190,6 +199,10 @@ class ContainerProviderClient(AbstractProvider):
190
199
  f"ContainerProviderClient: POST {self._url}/run "
191
200
  f"sim_type='{unit_config.sim_type}' run_id='{run_id}'"
192
201
  )
202
+ logger.info(
203
+ f"Waiting for '{self._ptype}' container at {self._url} to finish "
204
+ f"simulation — this may take a while…"
205
+ )
193
206
 
194
207
  payload = json.dumps(body).encode()
195
208
  req = urllib.request.Request(
@@ -56,11 +56,20 @@ class CoolPropProvider(AbstractProvider):
56
56
  logger.warning(f"Component '{comp}' not found in CoolProp. Cp contribution is 0.")
57
57
 
58
58
  Ks = {}
59
+ from CoolProp.CoolProp import AbstractState
60
+
59
61
  for comp in z.keys():
60
62
  try:
61
- fugL = self.CP.PropsSI("fugL", "T", T, "P", P, comp)
62
- fugV = self.CP.PropsSI("fugV", "T", T, "P", P, comp)
63
- Ks[comp] = fugL / fugV if fugV != 0 else 1.0
63
+ st = AbstractState("PR", comp)
64
+ st.specify_phase(self.CP.iphase_liquid)
65
+ st.update(self.CP.PT_INPUTS, P, T)
66
+ phi_L = st.fugacity_coefficient(0)
67
+ st.unspecify_phase()
68
+ st.specify_phase(self.CP.iphase_gas)
69
+ st.update(self.CP.PT_INPUTS, P, T)
70
+ phi_V = st.fugacity_coefficient(0)
71
+ st.unspecify_phase()
72
+ Ks[comp] = phi_L / phi_V if phi_V != 0 else 1.0
64
73
  except Exception:
65
74
  logger.warning(f"Could not calculate K-value for '{comp}'. Using fallback K=1.0.")
66
75
  Ks[comp] = 1.0
@@ -226,46 +226,80 @@ class StateManager:
226
226
  # Drift & structural diff
227
227
  # ------------------------------------------------------------------
228
228
 
229
+ # Keys treated as structural (handled by detect_structural_diff / cold
230
+ # start) rather than as drifted solve parameters.
231
+ _STRUCTURAL_UNIT_KEYS = {"type", "in", "out", "out_vap", "out_liq", "provider"}
232
+
233
+ # Top-level sections that never affect the solve and must be ignored when
234
+ # computing drift (cosmetic metadata, and the runtime-injected source path
235
+ # which is only present on the live config).
236
+ _DRIFT_SKIP_TOP_LEVEL = {"metadata", "_config_path"}
237
+
229
238
  def detect_drift(self, current_config: dict, state: SnapshotState | dict) -> list[str]:
230
239
  """Return parameter paths that differ between ``current_config`` and saved state.
231
240
 
241
+ Recursively diffs the entire config so any solve-affecting change is
242
+ detected — streams, units (incl. nested ``solver_config`` /
243
+ ``geometry_config`` / ``parameters``), materials, material mixes,
244
+ providers and simulation settings. Structural unit keys (``type``,
245
+ ``in``, ``out``, ``provider``, …) and the cosmetic ``metadata`` /
246
+ ``_config_path`` sections are excluded so topology changes remain the
247
+ job of :meth:`detect_structural_diff` / cold start.
248
+
232
249
  Paths look like ``"streams.feed.T"``, ``"units.pump_1.delta_p"``,
233
- or ``"units.pump_1.parameters.X"``.
250
+ ``"units.openmc_solver.solver_config.batches"`` or
251
+ ``"materials.salt.density"``.
234
252
  """
235
253
  old_config = state.config if isinstance(state, SnapshotState) else state["config"]
236
254
  drifted: list[str] = []
237
255
 
238
- for s_name, s_data in current_config.get("streams", {}).items():
239
- old_s = old_config.get("streams", {}).get(s_name, {})
240
- for param in ("T", "P", "flowrate"):
241
- if s_data.get(param) != old_s.get(param):
242
- drifted.append(f"streams.{s_name}.{param}")
243
-
244
- cur_z = s_data.get("z", {})
245
- old_z = old_s.get("z", {})
246
- for comp in cur_z:
247
- if cur_z.get(comp) != old_z.get(comp):
248
- drifted.append(f"streams.{s_name}.z.{comp}")
256
+ for section in set(old_config) | set(current_config):
257
+ if section in self._DRIFT_SKIP_TOP_LEVEL:
258
+ continue
259
+ old_section = old_config.get(section)
260
+ new_section = current_config.get(section)
261
+
262
+ if section == "units":
263
+ # Diff each unit subtree, skipping structural keys.
264
+ old_units = old_section or {}
265
+ new_units = new_section or {}
266
+ for u_name in set(old_units) | set(new_units):
267
+ drifted.extend(
268
+ self._recursive_diff(
269
+ old_units.get(u_name, {}),
270
+ new_units.get(u_name, {}),
271
+ prefix=f"units.{u_name}.",
272
+ skip=self._STRUCTURAL_UNIT_KEYS,
273
+ )
274
+ )
275
+ else:
276
+ drifted.extend(
277
+ self._recursive_diff(old_section, new_section, prefix=f"{section}.", skip=set())
278
+ )
249
279
 
250
- _SKIP_KEYS = {"type", "in", "out", "out_vap", "out_liq", "provider", "parameters"}
280
+ return drifted
251
281
 
252
- for u_name, u_cfg in current_config.get("units", {}).items():
253
- old_u = old_config.get("units", {}).get(u_name, {})
282
+ @staticmethod
283
+ def _recursive_diff(old: object, new: object, prefix: str, skip: set[str]) -> list[str]:
284
+ """Recursively collect changed leaf paths between two (sub)configs."""
285
+ changes: list[str] = []
254
286
 
255
- # Top-level scalar keys (mirrors detect_structural_diff comparison)
256
- all_keys = set(u_cfg) | set(old_u)
257
- for k in all_keys - _SKIP_KEYS:
258
- if u_cfg.get(k) != old_u.get(k):
259
- drifted.append(f"units.{u_name}.{k}")
287
+ if isinstance(old, dict) and isinstance(new, dict):
288
+ for k in (set(old) | set(new)) - skip:
289
+ changes.extend(
290
+ StateManager._recursive_diff(old.get(k), new.get(k), f"{prefix}{k}.", skip)
291
+ )
292
+ return changes
260
293
 
261
- # Nested "parameters" dict
262
- cur_params = u_cfg.get("parameters", {})
263
- old_params = old_u.get("parameters", {})
264
- for pk in set(cur_params) | set(old_params):
265
- if cur_params.get(pk) != old_params.get(pk):
266
- drifted.append(f"units.{u_name}.parameters.{pk}")
294
+ if isinstance(old, list) and isinstance(new, list):
295
+ if old != new:
296
+ changes.append(prefix.rstrip("."))
297
+ return changes
267
298
 
268
- return drifted
299
+ # Leaf / type-mismatch comparison.
300
+ if old != new:
301
+ changes.append(prefix.rstrip("."))
302
+ return changes
269
303
 
270
304
  def detect_structural_diff(self, current_config: dict, state: SnapshotState | dict) -> dict:
271
305
  """Compare unit topology between ``current_config`` and saved state.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.3.21
3
+ Version: 0.3.23
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
@@ -31,6 +31,7 @@ Requires-Dist: zarr>=3.0
31
31
  Requires-Dist: pint<1.0,>=0.24
32
32
  Requires-Dist: coolprop>=7.2.0
33
33
  Requires-Dist: typer>=0.12
34
+ Requires-Dist: arrow>=1.3
34
35
  Provides-Extra: api
35
36
  Requires-Dist: fastapi>=0.110; extra == "api"
36
37
  Requires-Dist: uvicorn>=0.29; extra == "api"
@@ -10,6 +10,7 @@ zarr>=3.0
10
10
  pint<1.0,>=0.24
11
11
  coolprop>=7.2.0
12
12
  typer>=0.12
13
+ arrow>=1.3
13
14
 
14
15
  [api]
15
16
  fastapi>=0.110
@@ -487,6 +487,59 @@ class TestCmdPlan:
487
487
  assert any("[ERR] openmc" in m and "unreachable" in m for m in log_output)
488
488
 
489
489
 
490
+ class TestCheckProviders:
491
+ """Health-check + retry/poll behavior used by ``pf run``/``pf apply``."""
492
+
493
+ def _config(self, **providers):
494
+ return {"providers": providers}
495
+
496
+ def test_container_health_ok_logs_ok(self, log_output):
497
+ import processforge.cli.common as common_mod
498
+ from processforge.cli.common import check_providers
499
+
500
+ health = {"status": "ready", "provider_type": "openmc"}
501
+ with patch.object(
502
+ common_mod, "_ping_provider_health", return_value=(True, health)
503
+ ), patch.object(common_mod, "_resolve_provider_url", return_value="http://localhost:9001"):
504
+ check_providers(self._config(openmc={"type": "openmc"}), "fs.json")
505
+
506
+ assert any("=== Provider / Container Health ===" in m for m in log_output)
507
+ assert any("[OK] openmc" in m and "status=ready" in m for m in log_output)
508
+ assert not any("Waiting for" in m for m in log_output)
509
+
510
+ def test_container_health_retries_then_fails(self, log_output):
511
+ import processforge.cli.common as common_mod
512
+ from processforge.cli.common import check_providers
513
+
514
+ with patch.object(
515
+ common_mod, "_ping_provider_health", return_value=(False, "Connection refused")
516
+ ), patch.object(common_mod, "_resolve_provider_url", return_value="http://localhost:9001"), \
517
+ patch.object(common_mod, "HEALTH_MAX_ATTEMPTS", 3), \
518
+ patch.object(common_mod, "HEALTH_RETRY_DELAY", 0), \
519
+ patch("time.sleep", lambda *_: None), \
520
+ pytest.raises(SystemExit) as exc_info:
521
+ check_providers(self._config(openmc={"type": "openmc"}), "fs.json")
522
+
523
+ assert exc_info.value.code == 1
524
+ # Two "Waiting for …" poll messages (attempts 1 and 2 of 3) before failure.
525
+ wait_msgs = [m for m in log_output if "Waiting for" in m and "openmc" in m]
526
+ assert len(wait_msgs) == 2
527
+ assert any("[ERR] openmc" in m and "unreachable after 3" in m for m in log_output)
528
+
529
+ def test_pip_provider_logs_ok(self, log_output):
530
+ import processforge.providers.registry as registry_mod
531
+ from processforge.cli.common import check_providers
532
+
533
+ with patch.object(
534
+ registry_mod, "_PROVIDER_CATALOG",
535
+ {"coolprop": {"module": "processforge.providers.coolprop_provider",
536
+ "optional_dep": None}},
537
+ ):
538
+ check_providers(self._config(coolprop={"type": "coolprop"}), "fs.json")
539
+
540
+ assert any("[OK] coolprop" in m and "(pip — importable)" in m for m in log_output)
541
+
542
+
490
543
  class TestCmdApply:
491
544
  def _containerized_config(self, flowsheet_path):
492
545
  return {