processforge 0.3.5__tar.gz → 0.3.8__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 (120) hide show
  1. {processforge-0.3.5/src/processforge.egg-info → processforge-0.3.8}/PKG-INFO +1 -1
  2. {processforge-0.3.5 → processforge-0.3.8}/flowsheets/openmc/msre_eigenvalue.json +5 -5
  3. {processforge-0.3.5 → processforge-0.3.8}/pyproject.toml +1 -1
  4. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/cli/__init__.py +1 -1
  5. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/cli/apply.py +2 -1
  6. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/cli/common.py +45 -0
  7. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/cli/display.py +55 -0
  8. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/cli/init.py +89 -16
  9. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/cli/plan.py +11 -3
  10. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/cli/run.py +2 -0
  11. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/compose.py +20 -8
  12. processforge-0.3.8/src/processforge/lock.py +74 -0
  13. {processforge-0.3.5 → processforge-0.3.8/src/processforge.egg-info}/PKG-INFO +1 -1
  14. {processforge-0.3.5 → processforge-0.3.8}/tests/test_cli.py +42 -0
  15. {processforge-0.3.5 → processforge-0.3.8}/tests/test_init.py +32 -14
  16. processforge-0.3.5/src/processforge/lock.py +0 -41
  17. {processforge-0.3.5 → processforge-0.3.8}/LICENSE +0 -0
  18. {processforge-0.3.5 → processforge-0.3.8}/MANIFEST.in +0 -0
  19. {processforge-0.3.5 → processforge-0.3.8}/README.md +0 -0
  20. {processforge-0.3.5 → processforge-0.3.8}/flowsheets/closed-loop-chain.json +0 -0
  21. {processforge-0.3.5 → processforge-0.3.8}/flowsheets/custom/crimson/MUWS_HB.json +0 -0
  22. {processforge-0.3.5 → processforge-0.3.8}/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -0
  23. {processforge-0.3.5 → processforge-0.3.8}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
  24. {processforge-0.3.5 → processforge-0.3.8}/flowsheets/hydraulic-chain.json +0 -0
  25. {processforge-0.3.5 → processforge-0.3.8}/setup.cfg +0 -0
  26. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/__init__.py +0 -0
  27. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/__main__.py +0 -0
  28. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/_schema.py +0 -0
  29. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/analysis/__init__.py +0 -0
  30. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/analysis/dof.py +0 -0
  31. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/cli/diagram.py +0 -0
  32. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/cli/export_fmu.py +0 -0
  33. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/cli/export_modelica.py +0 -0
  34. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/cli/validate.py +0 -0
  35. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/__init__.py +0 -0
  36. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/backends/__init__.py +0 -0
  37. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/backends/base.py +0 -0
  38. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/backends/casadi_backend.py +0 -0
  39. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  40. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/backends/scipy_backend.py +0 -0
  41. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/flowsheet.py +0 -0
  42. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/jacobian.py +0 -0
  43. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/mixin.py +0 -0
  44. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/solver.py +0 -0
  45. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/stream_var.py +0 -0
  46. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/units/__init__.py +0 -0
  47. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/units/flash_eo.py +0 -0
  48. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/units/heater_eo.py +0 -0
  49. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/units/pipes_eo.py +0 -0
  50. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/units/pump_eo.py +0 -0
  51. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/units/strainer_eo.py +0 -0
  52. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/eo/units/valve_eo.py +0 -0
  53. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/flowsheet.py +0 -0
  54. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/fmu/__init__.py +0 -0
  55. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/fmu/_fmi_vars.py +0 -0
  56. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/fmu/builder.py +0 -0
  57. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/fmu/slave_template.py +0 -0
  58. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/modelica/__init__.py +0 -0
  59. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/modelica/mo_writer.py +0 -0
  60. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/modelica/omc_runner.py +0 -0
  61. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/modelica/transpiler.py +0 -0
  62. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/modelica/unit_equations.py +0 -0
  63. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/pcl/__init__.py +0 -0
  64. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/pcl/compiler.py +0 -0
  65. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/pcl/namespace.py +0 -0
  66. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/provenance.py +0 -0
  67. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/__init__.py +0 -0
  68. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/base.py +0 -0
  69. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/base_jacobian_mixin.py +0 -0
  70. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/cantera_jacobian.py +0 -0
  71. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/cantera_provider.py +0 -0
  72. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/container_client.py +0 -0
  73. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/coolprop_provider.py +0 -0
  74. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/festim_provider.py +0 -0
  75. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/jacobian_contributor.py +0 -0
  76. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/manager.py +0 -0
  77. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/modelica_jacobian.py +0 -0
  78. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/modelica_provider.py +0 -0
  79. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/openmc_provider.py +0 -0
  80. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/reference_state_registry.py +0 -0
  81. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/providers/registry.py +0 -0
  82. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/result.py +0 -0
  83. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/result_schema.py +0 -0
  84. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/schemas/__init__.py +0 -0
  85. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/schemas/flowsheet_schema.json +0 -0
  86. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/schemas/openmc/openmc_model.py +0 -0
  87. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/simulate.py +0 -0
  88. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/solver.py +0 -0
  89. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/state.py +0 -0
  90. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/thermo.py +0 -0
  91. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/types.py +0 -0
  92. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/__init__.py +0 -0
  93. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/base.py +0 -0
  94. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/cstr.py +0 -0
  95. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/flash.py +0 -0
  96. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/heater.py +0 -0
  97. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/pfr.py +0 -0
  98. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/pipes.py +0 -0
  99. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/pump.py +0 -0
  100. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/registry.py +0 -0
  101. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/solver_unit.py +0 -0
  102. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/strainer.py +0 -0
  103. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/tank.py +0 -0
  104. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/units/valve.py +0 -0
  105. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/utils/__init__.py +0 -0
  106. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/utils/flowsheet_diagram.py +0 -0
  107. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/utils/mermaid_diagram.py +0 -0
  108. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/utils/topology.py +0 -0
  109. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/utils/unit_consistency.py +0 -0
  110. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/utils/validate_flowsheet.py +0 -0
  111. {processforge-0.3.5 → processforge-0.3.8}/src/processforge/utils/validation.py +0 -0
  112. {processforge-0.3.5 → processforge-0.3.8}/src/processforge.egg-info/SOURCES.txt +0 -0
  113. {processforge-0.3.5 → processforge-0.3.8}/src/processforge.egg-info/dependency_links.txt +0 -0
  114. {processforge-0.3.5 → processforge-0.3.8}/src/processforge.egg-info/entry_points.txt +0 -0
  115. {processforge-0.3.5 → processforge-0.3.8}/src/processforge.egg-info/requires.txt +0 -0
  116. {processforge-0.3.5 → processforge-0.3.8}/src/processforge.egg-info/top_level.txt +0 -0
  117. {processforge-0.3.5 → processforge-0.3.8}/tests/test_festim_provider.py +0 -0
  118. {processforge-0.3.5 → processforge-0.3.8}/tests/test_providers.py +0 -0
  119. {processforge-0.3.5 → processforge-0.3.8}/tests/test_state.py +0 -0
  120. {processforge-0.3.5 → processforge-0.3.8}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.3.5
3
+ Version: 0.3.8
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
@@ -17,7 +17,7 @@
17
17
  "id": 1,
18
18
  "density": 2.26,
19
19
  "density_units": "g/cm3",
20
- "temperature": 0.0,
20
+ "temperature": 300.0,
21
21
  "elements": [
22
22
  { "element": "C", "percent": 1.0, "percent_type": "ao" }
23
23
  ]
@@ -26,7 +26,7 @@
26
26
  "id": 2,
27
27
  "density": 9.0,
28
28
  "density_units": "g/cm3",
29
- "temperature": 0.0,
29
+ "temperature": 400.0,
30
30
  "elements": [
31
31
  { "element": "Ni", "percent": 0.72, "percent_type": "wo" },
32
32
  { "element": "Mo", "percent": 0.16, "percent_type": "wo" },
@@ -38,7 +38,7 @@
38
38
  "id": 3,
39
39
  "density": 2.2,
40
40
  "density_units": "g/cm3",
41
- "temperature": 0.0,
41
+ "temperature": 300.0,
42
42
  "depletable": true,
43
43
  "nuclides": [
44
44
  { "name": "Li7", "percent": 0.335000, "percent_type": "ao" },
@@ -55,7 +55,7 @@
55
55
  "id": 4,
56
56
  "density": 8.9,
57
57
  "density_units": "g/cm3",
58
- "temperature": 0.0,
58
+ "temperature": 300.0,
59
59
  "elements": [
60
60
  { "element": "Ni", "percent": 0.72, "percent_type": "ao" },
61
61
  { "element": "Cr", "percent": 0.20, "percent_type": "ao" },
@@ -66,7 +66,7 @@
66
66
  "id": 5,
67
67
  "density": 1.0e-4,
68
68
  "density_units": "g/cm3",
69
- "temperature": 0.0,
69
+ "temperature": 300.0,
70
70
  "nuclides": [
71
71
  { "name": "He4", "percent": 1.0, "percent_type": "ao" }
72
72
  ]
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "processforge"
7
- version = "0.3.5"
7
+ version = "0.3.8"
8
8
  description = "A Python-based process simulation framework for chemical engineering applications."
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -31,7 +31,7 @@ def register_commands(app: typer.Typer) -> None:
31
31
  )(apply)
32
32
  app.command(
33
33
  "plan",
34
- help="Validate a flowsheet, run DOF analysis, structural diff, and generate a Mermaid diagram",
34
+ help="Validate a flowsheet, run DOF analysis, structural diff, container health check, and generate a Mermaid diagram",
35
35
  )(plan)
36
36
  app.command("diagram", help="Generate a flowsheet diagram")(diagram)
37
37
  app.command(
@@ -17,6 +17,7 @@ from .common import (
17
17
  build_divergence_report,
18
18
  build_run_metadata,
19
19
  check_providers,
20
+ display_backend,
20
21
  flowsheet_basename,
21
22
  load_state_manager,
22
23
  log_residual_breakdown,
@@ -132,7 +133,7 @@ def apply(
132
133
  logger.info(" Status : CONVERGED")
133
134
  logger.info(f" Final ||F|| : {fs.solver_stats.get('final_norm', '?'):.3e}")
134
135
  logger.info(f" Iterations : {fs.solver_stats.get('iterations', '?')}")
135
- logger.info(f" Backend : {fs.backend}")
136
+ logger.info(f" Backend : {display_backend(config, fs.backend)}")
136
137
  logger.info(f" Snapshot ID : {snapshot_id}")
137
138
  logger.info(f" Results zarr : {zarr_path}")
138
139
  logger.info(f" Elapsed (s) : {elapsed:.2f}")
@@ -98,6 +98,23 @@ def build_run_metadata(config: dict, solver_tol: float, solver_max_iter: int, ba
98
98
  }
99
99
 
100
100
 
101
+ def display_backend(config: dict, eo_backend: str) -> str:
102
+ """Resolve the backend label shown in run/apply summaries.
103
+
104
+ When a flowsheet delegates its computation to one or more providers
105
+ (e.g. ``openmc``, ``cantera``), the provider type is the engine that
106
+ actually ran the simulation — report that rather than the EO solver
107
+ backend (which is a no-op for provider-driven flowsheets). Falls back
108
+ to the EO solver backend when no providers are declared.
109
+ """
110
+ provider_types = sorted(
111
+ {p.get("type") for p in config.get("providers", {}).values() if p.get("type")}
112
+ )
113
+ if provider_types:
114
+ return "+".join(provider_types)
115
+ return eo_backend
116
+
117
+
101
118
  def extract_providers(flowsheet_path: str) -> dict:
102
119
  """Read flowsheet JSON and return the raw providers dict."""
103
120
  try:
@@ -128,6 +145,21 @@ def load_state_manager(outputs_dir: str, base_name: str) -> tuple[StateManager,
128
145
  # Provider checking
129
146
  # ---------------------------------------------------------------------------
130
147
 
148
+ def is_local_provider_url(url: str | None) -> bool:
149
+ """Return True if *url* points at a locally-managed provider.
150
+
151
+ A provider is considered local — and thus a candidate for ``pf init`` to
152
+ generate docker-compose and pull its image — when it has no explicit URL
153
+ or its URL points at localhost / 127.0.0.1 / 0.0.0.0. Any other URL is
154
+ treated as an externally-managed (cloud / remote) provider that
155
+ ``pf init`` must not try to spin up or pull an image for.
156
+ """
157
+ if not url:
158
+ return True
159
+ lowered = url.lower()
160
+ return any(token in lowered for token in ("localhost", "127.0.0.1", "0.0.0.0"))
161
+
162
+
131
163
  def _resolve_provider_url(cfg: dict, ptype: str) -> str:
132
164
  """Build the provider URL, falling back to the default port."""
133
165
  from ..providers.registry import get_provider_default_port
@@ -139,6 +171,19 @@ def _resolve_provider_url(cfg: dict, ptype: str) -> str:
139
171
  return url
140
172
 
141
173
 
174
+ def _ping_provider_health(url: str, timeout: int = 5) -> tuple[bool, "dict | str"]:
175
+ """GET ``{url}/health`` and return ``(ok, payload_or_error)``.
176
+
177
+ Returns the parsed JSON health payload on success, or the error string on
178
+ failure (unreachable service, timeout, or non-JSON response).
179
+ """
180
+ try:
181
+ with urllib.request.urlopen(f"{url.rstrip('/')}/health", timeout=timeout) as resp:
182
+ return True, json.loads(resp.read().decode())
183
+ except (urllib.error.URLError, OSError, TimeoutError, ValueError) as exc:
184
+ return False, str(exc)
185
+
186
+
142
187
  def check_providers(
143
188
  config: dict,
144
189
  flowsheet_path: str,
@@ -53,6 +53,61 @@ def print_unit_mismatches(mismatches: list[Any]) -> None:
53
53
  logger.warning(f"Unit annotation — stream '{m.stream_name}'.{m.property_name}: {m.message}")
54
54
 
55
55
 
56
+ def print_provider_health(config: dict) -> list[str]:
57
+ """Log per-provider reachability for flowsheets using Docker containers.
58
+
59
+ For each provider declared in *config*:
60
+
61
+ * containerized providers are probed via ``GET /health`` and reported with
62
+ their URL, ``status``, and ``provider_type`` from the health payload;
63
+ * pip-installable providers are reported as importable or not.
64
+
65
+ Returns a list of failure messages — one per unreachable containerized
66
+ provider (empty means every provider is healthy/available).
67
+ """
68
+ import importlib
69
+
70
+ from ..providers.registry import is_containerized, _PROVIDER_CATALOG
71
+
72
+ providers = config.get("providers", {})
73
+ logger.info("=== Provider / Container Health ===")
74
+ failures: list[str] = []
75
+ if not providers:
76
+ logger.info(" No providers declared.")
77
+ return failures
78
+
79
+ for name, cfg in providers.items():
80
+ ptype = cfg.get("type", "")
81
+ if is_containerized(ptype):
82
+ from .common import _resolve_provider_url, _ping_provider_health
83
+
84
+ url = _resolve_provider_url(cfg, ptype)
85
+ ok, info = _ping_provider_health(url, timeout=5)
86
+ if ok:
87
+ payload = info if isinstance(info, dict) else {}
88
+ status = payload.get("status", "?")
89
+ provider_type = payload.get("provider_type", "?")
90
+ logger.info(
91
+ f" [OK] {name} [{ptype}] {url} — status={status} provider_type={provider_type}"
92
+ )
93
+ else:
94
+ msg = f" [ERR] {name} [{ptype}] {url} — unreachable: {info}"
95
+ logger.error(msg)
96
+ failures.append(f"Provider '{name}' unreachable at {url}: {info}")
97
+ else:
98
+ catalog = _PROVIDER_CATALOG.get(ptype, {})
99
+ module = catalog.get("module", "")
100
+ try:
101
+ importlib.util.find_spec(module)
102
+ logger.info(f" [OK] {name} [{ptype}] (pip — importable)")
103
+ except (ModuleNotFoundError, ValueError):
104
+ dep = catalog.get("optional_dep")
105
+ hint = f"pip install 'processforge[{dep}]'" if dep else "built-in"
106
+ logger.warning(f" [WARN] {name} [{ptype}] — not installed ({hint})")
107
+
108
+ return failures
109
+
110
+
56
111
  def print_structural_diff(diff: dict) -> None:
57
112
  """Print a +/~/- structural diff of units."""
58
113
  logger.info("=== Structural Diff vs. Saved State ===")
@@ -11,7 +11,56 @@ import subprocess
11
11
  import typer
12
12
  from loguru import logger
13
13
 
14
- from .common import extract_providers
14
+ from .common import extract_providers, is_local_provider_url
15
+ from ..lock import flowsheet_env_dir, read_lock
16
+
17
+
18
+ def _migrate_legacy_env(pf_dir: str) -> None:
19
+ """Move a legacy root-level lock.json / docker-compose.yml into a per-flowsheet dir.
20
+
21
+ Older processforge stored a single environment at ``.processforge/lock.json``
22
+ and ``.processforge/docker-compose.yml``. This relocates those into the hashed
23
+ env dir derived from the flowsheet recorded in the legacy lock, so an existing
24
+ repo keeps its provider environment after upgrading.
25
+ """
26
+ legacy_lock = os.path.join(pf_dir, "lock.json")
27
+ legacy_compose = os.path.join(pf_dir, "docker-compose.yml")
28
+ if not (os.path.exists(legacy_lock) or os.path.exists(legacy_compose)):
29
+ return
30
+
31
+ recorded = None
32
+ if os.path.exists(legacy_lock):
33
+ try:
34
+ recorded = read_lock(pf_dir).get("flowsheet")
35
+ except Exception:
36
+ recorded = None
37
+
38
+ if recorded and os.path.exists(recorded):
39
+ env_dir = flowsheet_env_dir(pf_dir, recorded)
40
+ else:
41
+ base = os.path.splitext(os.path.basename(recorded or "legacy"))[0] or "legacy"
42
+ env_dir = os.path.join(pf_dir, base)
43
+ logger.warning(
44
+ "Migrating legacy .processforge/lock.json + docker-compose.yml into "
45
+ f"'{os.path.relpath(env_dir, pf_dir)}/' (recorded flowsheet not found on "
46
+ "disk — re-run `pf init <flowsheet.json>` to restore the correct env dir)."
47
+ )
48
+
49
+ if os.path.exists(env_dir):
50
+ logger.warning(
51
+ f"Migration target {os.path.relpath(env_dir, pf_dir)}/ already exists — "
52
+ "skipping legacy migration."
53
+ )
54
+ return
55
+
56
+ os.makedirs(env_dir, exist_ok=True)
57
+ for src in (legacy_lock, legacy_compose):
58
+ if os.path.exists(src):
59
+ shutil.move(src, os.path.join(env_dir, os.path.basename(src)))
60
+ logger.info(
61
+ "Migrated legacy .processforge/ environment into per-flowsheet dir "
62
+ f"'{os.path.relpath(env_dir, pf_dir)}/'."
63
+ )
15
64
 
16
65
 
17
66
  def init(
@@ -42,6 +91,11 @@ def init(
42
91
  os.makedirs(pf_dir, exist_ok=True)
43
92
  os.makedirs(outputs_dir, exist_ok=True)
44
93
 
94
+ # Migrate a legacy single-environment layout (.processforge/lock.json and
95
+ # .processforge/docker-compose.yml at the root) into a per-flowsheet env
96
+ # dir so existing repos aren't silently broken by the new structure.
97
+ _migrate_legacy_env(pf_dir)
98
+
45
99
  # Remove stale .pfstate snapshot directories from outputs/
46
100
  stale_count = 0
47
101
  for entry in os.listdir(outputs_dir):
@@ -84,21 +138,32 @@ def init(
84
138
 
85
139
  # Categorize providers
86
140
  pip_providers: dict[str, dict] = {}
87
- docker_providers: dict[str, dict] = {}
141
+ local_docker_providers: dict[str, dict] = {}
142
+ remote_docker_providers: dict[str, dict] = {}
88
143
  for name, cfg in providers.items():
89
144
  ptype = cfg.get("type", "")
90
145
  if is_containerized(ptype):
91
146
  url = cfg.get("url")
92
- if not url:
147
+ if is_local_provider_url(url):
93
148
  port = get_provider_default_port(ptype) or 9000
94
- url = f"http://localhost:{port}"
95
- docker_providers[name] = {
96
- "type": ptype,
97
- "url": url,
98
- "docker_image": cfg.get("docker_image") or get_provider_docker_image(ptype),
99
- "port": get_provider_default_port(ptype),
100
- }
101
- logger.info(f" {name}: type={ptype}, url={url} (Docker)")
149
+ if not url:
150
+ url = f"http://localhost:{port}"
151
+ local_docker_providers[name] = {
152
+ "type": ptype,
153
+ "url": url,
154
+ "docker_image": cfg.get("docker_image") or get_provider_docker_image(ptype),
155
+ "port": port,
156
+ }
157
+ logger.info(f" {name}: type={ptype}, url={url} (Docker, local)")
158
+ else:
159
+ remote_docker_providers[name] = {
160
+ "type": ptype,
161
+ "url": url,
162
+ "docker_image": cfg.get("docker_image") or get_provider_docker_image(ptype),
163
+ }
164
+ logger.info(
165
+ f" {name}: type={ptype}, url={url} (Docker, remote — skipping compose)"
166
+ )
102
167
  else:
103
168
  pip_providers[name] = {"type": ptype}
104
169
  logger.info(f" {name}: type={ptype} (pip)")
@@ -116,15 +181,19 @@ def init(
116
181
  hint = f"pip install 'processforge[{dep}]'" if dep else "built-in"
117
182
  logger.warning(f" {name} — not installed. Install with: {hint}")
118
183
 
119
- # Generate compose for Docker providers
120
- if docker_providers:
121
- compose_path = os.path.join(pf_dir, "docker-compose.yml")
184
+ # Generate compose + pull images only for locally-managed Docker providers.
185
+ # Providers with an explicit remote URL are assumed to be running elsewhere
186
+ # (e.g. a cloud deployment of the ghcr.io image) and are not touched here.
187
+ if local_docker_providers:
188
+ compose_path = os.path.join(
189
+ flowsheet_env_dir(pf_dir, flowsheet_path), "docker-compose.yml"
190
+ )
122
191
  if os.path.exists(compose_path):
123
192
  logger.warning(
124
193
  f"Environment already initialized — reinitializing from {flowsheet_path}"
125
194
  )
126
195
 
127
- generate_compose(pf_dir, docker_providers, outputs_dir)
196
+ generate_compose(pf_dir, local_docker_providers, outputs_dir, flowsheet=flowsheet_path)
128
197
  logger.info(f"Generated {compose_path}")
129
198
 
130
199
  # Attempt docker compose pull — stream progress live
@@ -155,6 +224,10 @@ def init(
155
224
  )
156
225
  except subprocess.TimeoutExpired:
157
226
  logger.warning("docker compose pull timed out after 600s.")
227
+ elif remote_docker_providers:
228
+ logger.info(
229
+ "All containerized providers use remote URLs — skipping Docker setup."
230
+ )
158
231
  else:
159
232
  logger.info("No containerized providers — skipping Docker setup.")
160
233
 
@@ -177,5 +250,5 @@ def init(
177
250
  from .. import __version__ as pf_version
178
251
 
179
252
  write_lock(pf_dir, flowsheet_path, lock_providers, pf_version)
180
- logger.info(f"Wrote {os.path.join(pf_dir, 'lock.json')}")
253
+ logger.info(f"Wrote {os.path.join(flowsheet_env_dir(pf_dir, flowsheet_path), 'lock.json')}")
181
254
  logger.info(".processforge/ initialised successfully.")
@@ -18,7 +18,12 @@ from .common import (
18
18
  require_existing_file,
19
19
  validate_snapshot_config,
20
20
  )
21
- from .display import print_dof_report, print_structural_diff, print_unit_mismatches
21
+ from .display import (
22
+ print_dof_report,
23
+ print_provider_health,
24
+ print_structural_diff,
25
+ print_unit_mismatches,
26
+ )
22
27
 
23
28
 
24
29
  def plan(
@@ -116,7 +121,10 @@ def plan(
116
121
  logger.info(" Warm-start available : No snapshot found")
117
122
  logger.info(" Homotopy eligible : No (cold start)")
118
123
 
119
- # Step 7: Mermaid diagram
124
+ # Step 7: Provider / container health check
125
+ health_failures = print_provider_health(config)
126
+
127
+ # Step 8: Mermaid diagram
120
128
  if not no_diagram:
121
129
  diagram_output_dir = output_dir or "diagrams"
122
130
  os.makedirs(diagram_output_dir, exist_ok=True)
@@ -129,5 +137,5 @@ def plan(
129
137
 
130
138
  # Exit non-zero on hard errors
131
139
  hard_errors = [m for m in mismatches if not m.compatible]
132
- if hard_errors or report.system_dof < 0:
140
+ if hard_errors or report.system_dof < 0 or health_failures:
133
141
  raise SystemExit(1)
@@ -14,6 +14,7 @@ from ..result import plot_results, plot_timeseries, save_results_zarr
14
14
  from ..state import StateManager
15
15
  from .common import (
16
16
  check_providers,
17
+ display_backend,
17
18
  flowsheet_basename,
18
19
  output_root,
19
20
  require_existing_file,
@@ -96,6 +97,7 @@ def run(
96
97
  os.makedirs(outputs_dir, exist_ok=True)
97
98
  zarr_path = os.path.join(outputs_dir, f"{base_name}_results.zarr")
98
99
  save_results_zarr(results, zarr_path, run_info=run_info)
100
+ logger.info(f" Backend : {display_backend(config, getattr(fs, 'backend', 'dynamic'))}")
99
101
  logger.info(f"Results saved to {zarr_path}")
100
102
 
101
103
  if export_images:
@@ -5,6 +5,8 @@ import os
5
5
 
6
6
  from loguru import logger
7
7
 
8
+ from .lock import flowsheet_env_dir
9
+
8
10
  COMPOSE_FILENAME = "docker-compose.yml"
9
11
  COMPOSE_HEADER = (
10
12
  "# Auto-generated by pf init — do not edit manually.\n"
@@ -12,7 +14,9 @@ COMPOSE_HEADER = (
12
14
  )
13
15
 
14
16
 
15
- def _compose_path(pf_dir: str) -> str:
17
+ def _compose_path(pf_dir: str, flowsheet: str | None = None) -> str:
18
+ if flowsheet is not None:
19
+ return os.path.join(flowsheet_env_dir(pf_dir, flowsheet), COMPOSE_FILENAME)
16
20
  return os.path.join(pf_dir, COMPOSE_FILENAME)
17
21
 
18
22
 
@@ -20,6 +24,7 @@ def generate_compose(
20
24
  pf_dir: str,
21
25
  containerized_providers: dict[str, dict],
22
26
  outputs_dir: str = "outputs",
27
+ flowsheet: str | None = None,
23
28
  ) -> None:
24
29
  """Generate docker-compose.yml for providers that need Docker.
25
30
 
@@ -27,6 +32,9 @@ def generate_compose(
27
32
  pf_dir: Path to .processforge/ directory.
28
33
  containerized_providers: Dict of provider_name -> {"url": ..., "docker_image": ..., "port": ...}.
29
34
  outputs_dir: Host directory to mount as /data in containers.
35
+ flowsheet: Flowsheet path. When given, the compose file is written into
36
+ that flowsheet's per-flowsheet environment directory; otherwise it
37
+ is written to the legacy root location.
30
38
  """
31
39
  if not containerized_providers:
32
40
  return
@@ -37,16 +45,20 @@ def generate_compose(
37
45
  port = info.get("port") or 9000
38
46
  lines.append(f" {name}:")
39
47
  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")
48
+ # Publish the provider's API-contract port (host:container) so the
49
+ # service is reachable at http://localhost:<port>, matching the
50
+ # PORT/EXPOSE declared in the provider Dockerfile.
51
+ lines.append(f" ports:")
52
+ lines.append(f' - "{port}:{port}"')
45
53
  lines.append(f" volumes:")
46
54
  lines.append(f' - "${{OPENMC_DATA_ROOT:-{outputs_dir}}}:/data"')
47
55
  lines.append("")
48
56
 
49
- os.makedirs(pf_dir, exist_ok=True)
50
- path = _compose_path(pf_dir)
57
+ if flowsheet is not None:
58
+ target_dir = flowsheet_env_dir(pf_dir, flowsheet)
59
+ else:
60
+ target_dir = pf_dir
61
+ os.makedirs(target_dir, exist_ok=True)
62
+ path = os.path.join(target_dir, COMPOSE_FILENAME)
51
63
  with open(path, "w", encoding="utf-8") as f:
52
64
  f.write("\n".join(lines))
@@ -0,0 +1,74 @@
1
+ """Lock file read/write for .processforge/<env-key>/lock.json.
2
+
3
+ Each flowsheet gets its own environment directory under ``.processforge/``,
4
+ keyed by a stable hash of the flowsheet's absolute path. This lets multiple
5
+ flowsheets keep separate provider environments (lock + compose) without
6
+ clobbering each other. The lock file is provenance metadata only.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from typing import Any
14
+
15
+ LOCK_VERSION = 1
16
+ LOCK_FILENAME = "lock.json"
17
+
18
+
19
+ def flowsheet_env_key(flowsheet_path: str) -> str:
20
+ """Stable key for a flowsheet's environment dir.
21
+
22
+ Derived from the sha1 of the flowsheet's real (absolute) path so it is
23
+ collision-proof across folders and stable across re-inits.
24
+ """
25
+ abs_path = os.path.realpath(flowsheet_path)
26
+ return hashlib.sha1(abs_path.encode("utf-8")).hexdigest()[:12]
27
+
28
+
29
+ def flowsheet_env_dir(pf_dir: str, flowsheet_path: str) -> str:
30
+ """Directory holding a flowsheet's lock.json and docker-compose.yml."""
31
+ return os.path.join(pf_dir, flowsheet_env_key(flowsheet_path))
32
+
33
+
34
+ def _lock_path(pf_dir: str, flowsheet: str | None = None) -> str:
35
+ if flowsheet is not None:
36
+ return os.path.join(flowsheet_env_dir(pf_dir, flowsheet), LOCK_FILENAME)
37
+ return os.path.join(pf_dir, LOCK_FILENAME)
38
+
39
+
40
+ def read_lock(pf_dir: str, flowsheet: str | None = None) -> dict[str, Any] | None:
41
+ """Read lock.json.
42
+
43
+ When *flowsheet* is given, reads that flowsheet's env dir. Otherwise falls
44
+ back to the legacy root ``.processforge/lock.json`` (used during migration).
45
+ Returns None if not found.
46
+ """
47
+ path = _lock_path(pf_dir, flowsheet)
48
+ if not os.path.exists(path):
49
+ if flowsheet is None and os.path.exists(os.path.join(pf_dir, LOCK_FILENAME)):
50
+ path = os.path.join(pf_dir, LOCK_FILENAME)
51
+ else:
52
+ return None
53
+ with open(path, "r", encoding="utf-8") as f:
54
+ return json.load(f)
55
+
56
+
57
+ def write_lock(
58
+ pf_dir: str,
59
+ flowsheet: str,
60
+ providers: dict[str, dict],
61
+ processforge_version: str,
62
+ ) -> None:
63
+ """Write lock.json into the flowsheet's environment directory."""
64
+ lock = {
65
+ "version": LOCK_VERSION,
66
+ "processforge_version": processforge_version,
67
+ "flowsheet": flowsheet,
68
+ "providers": providers,
69
+ }
70
+ env_dir = flowsheet_env_dir(pf_dir, flowsheet)
71
+ os.makedirs(env_dir, exist_ok=True)
72
+ path = os.path.join(env_dir, LOCK_FILENAME)
73
+ with open(path, "w", encoding="utf-8") as f:
74
+ json.dump(lock, f, indent=2)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.3.5
3
+ Version: 0.3.8
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
@@ -442,6 +442,48 @@ class TestCmdRun:
442
442
  run(flowsheet=str(flowsheet), export_images=False)
443
443
 
444
444
 
445
+ class TestCmdPlan:
446
+ @pytest.fixture
447
+ def container_flowsheet(self, tmp_path):
448
+ """Write a minimal valid flowsheet using a Docker-containerized provider."""
449
+ flowsheet = {
450
+ "providers": {"openmc": {"type": "openmc"}},
451
+ "streams": {},
452
+ "units": {},
453
+ "simulation": {"mode": "steady"},
454
+ }
455
+ path = tmp_path / "container.json"
456
+ with open(path, "w") as f:
457
+ json.dump(flowsheet, f)
458
+ return path
459
+
460
+ def test_container_provider_health_ok(self, container_flowsheet, log_output):
461
+ import processforge.cli.common as common_mod
462
+ from processforge.cli.plan import plan
463
+
464
+ health = {"status": "ready", "provider_type": "openmc"}
465
+ with patch.object(
466
+ common_mod, "_ping_provider_health", return_value=(True, health)
467
+ ), patch.object(common_mod, "_resolve_provider_url", return_value="http://localhost:9001"):
468
+ plan(flowsheet=str(container_flowsheet), no_diagram=True)
469
+
470
+ assert any("Provider / Container Health" in m for m in log_output)
471
+ assert any("[OK] openmc" in m and "provider_type=openmc" in m for m in log_output)
472
+
473
+ def test_container_provider_health_failure_exits(self, container_flowsheet, log_output):
474
+ import processforge.cli.common as common_mod
475
+ from processforge.cli.plan import plan
476
+
477
+ with patch.object(
478
+ common_mod, "_ping_provider_health", return_value=(False, "Connection refused")
479
+ ), patch.object(common_mod, "_resolve_provider_url", return_value="http://localhost:9001"), \
480
+ pytest.raises(SystemExit) as exc_info:
481
+ plan(flowsheet=str(container_flowsheet), no_diagram=True)
482
+
483
+ assert exc_info.value.code == 1
484
+ assert any("[ERR] openmc" in m and "unreachable" in m for m in log_output)
485
+
486
+
445
487
  class TestCmdApply:
446
488
  def _containerized_config(self, flowsheet_path):
447
489
  return {
@@ -8,7 +8,7 @@ from unittest.mock import patch
8
8
 
9
9
  import pytest
10
10
 
11
- from processforge.lock import read_lock, write_lock, LOCK_VERSION
11
+ from processforge.lock import read_lock, write_lock, LOCK_VERSION, flowsheet_env_dir
12
12
  from processforge.compose import generate_compose, COMPOSE_FILENAME
13
13
  from processforge.providers.registry import (
14
14
  _PROVIDER_CATALOG,
@@ -153,7 +153,7 @@ class TestLockFile:
153
153
  "coolprop": {"docker_image": None, "url": None},
154
154
  }
155
155
  write_lock(str(tmp_pf_dir), "test.json", providers, "0.2.34")
156
- lock = read_lock(str(tmp_pf_dir))
156
+ lock = read_lock(str(tmp_pf_dir), "test.json")
157
157
 
158
158
  assert lock is not None
159
159
  assert lock["version"] == LOCK_VERSION
@@ -163,11 +163,27 @@ class TestLockFile:
163
163
  assert "coolprop" in lock["providers"]
164
164
  assert lock["providers"]["openmc"]["url"] == "http://localhost:9001"
165
165
 
166
+ def test_lock_written_to_per_flowsheet_dir(self, tmp_pf_dir):
167
+ write_lock(str(tmp_pf_dir), "a/test.json", {}, "0.2.34")
168
+ # No legacy root lock.json is written.
169
+ assert not (tmp_pf_dir / "lock.json").exists()
170
+ # Instead it lives in the hashed env dir.
171
+ assert (Path(flowsheet_env_dir(str(tmp_pf_dir), "a/test.json")) / "lock.json").exists()
172
+
173
+ def test_two_flowsheets_separate_dirs(self, tmp_pf_dir):
174
+ write_lock(str(tmp_pf_dir), "flows/a.json", {}, "0.2.34")
175
+ write_lock(str(tmp_pf_dir), "flows/b.json", {}, "0.2.34")
176
+ dir_a = Path(flowsheet_env_dir(str(tmp_pf_dir), "flows/a.json"))
177
+ dir_b = Path(flowsheet_env_dir(str(tmp_pf_dir), "flows/b.json"))
178
+ assert dir_a != dir_b
179
+ assert (dir_a / "lock.json").exists()
180
+ assert (dir_b / "lock.json").exists()
181
+
166
182
  def test_lock_creates_directory(self, tmp_path):
167
183
  pf_dir = tmp_path / ".processforge"
168
184
  providers = {"coolprop": {"docker_image": None, "url": None}}
169
185
  write_lock(str(pf_dir), "test.json", providers, "0.2.34")
170
- assert read_lock(str(pf_dir)) is not None
186
+ assert read_lock(str(pf_dir), "test.json") is not None
171
187
 
172
188
 
173
189
  # ---------------------------------------------------------------------------
@@ -183,28 +199,30 @@ class TestComposeGeneration:
183
199
  "port": 9001,
184
200
  },
185
201
  }
186
- generate_compose(str(tmp_pf_dir), docker_providers)
187
- compose_path = tmp_pf_dir / COMPOSE_FILENAME
188
- assert compose_path.exists()
202
+ generate_compose(str(tmp_pf_dir), docker_providers, flowsheet="a.json")
203
+ compose_path = os.path.join(flowsheet_env_dir(str(tmp_pf_dir), "a.json"), COMPOSE_FILENAME)
204
+ assert os.path.exists(compose_path)
189
205
 
190
- content = compose_path.read_text()
206
+ content = Path(compose_path).read_text()
191
207
  assert "services:" in content
192
208
  assert "openmc:" in content
193
209
  assert "ghcr.io/urjanova/processforge-openmc:latest" in content
194
210
  assert "9001:9001" in content
211
+ assert "ports:" in content
212
+ assert "network_mode" not in content
195
213
 
196
214
  def test_generate_compose_empty_when_no_docker(self, tmp_pf_dir):
197
- generate_compose(str(tmp_pf_dir), {})
198
- compose_path = tmp_pf_dir / COMPOSE_FILENAME
199
- assert not compose_path.exists()
215
+ generate_compose(str(tmp_pf_dir), {}, flowsheet="a.json")
216
+ compose_path = os.path.join(flowsheet_env_dir(str(tmp_pf_dir), "a.json"), COMPOSE_FILENAME)
217
+ assert not os.path.exists(compose_path)
200
218
 
201
219
  def test_generate_compose_multiple_providers(self, tmp_pf_dir):
202
220
  docker_providers = {
203
221
  "openmc": {"docker_image": "ghcr.io/test/openmc:latest", "port": 9001},
204
222
  "festim": {"docker_image": "ghcr.io/test/festim:latest", "port": 9002},
205
223
  }
206
- generate_compose(str(tmp_pf_dir), docker_providers)
207
- content = (tmp_pf_dir / COMPOSE_FILENAME).read_text()
224
+ generate_compose(str(tmp_pf_dir), docker_providers, flowsheet="a.json")
225
+ content = Path(os.path.join(flowsheet_env_dir(str(tmp_pf_dir), "a.json"), COMPOSE_FILENAME)).read_text()
208
226
  assert "openmc:" in content
209
227
  assert "festim:" in content
210
228
 
@@ -215,8 +233,8 @@ class TestComposeGeneration:
215
233
  "port": 9001,
216
234
  },
217
235
  }
218
- generate_compose(str(tmp_pf_dir), docker_providers)
219
- content = (tmp_pf_dir / COMPOSE_FILENAME).read_text()
236
+ generate_compose(str(tmp_pf_dir), docker_providers, flowsheet="a.json")
237
+ content = Path(os.path.join(flowsheet_env_dir(str(tmp_pf_dir), "a.json"), COMPOSE_FILENAME)).read_text()
220
238
  assert "my-org/custom-openmc:v2" in content
221
239
  assert "ghcr.io/urjanova/processforge-openmc" not in content
222
240
 
@@ -1,41 +0,0 @@
1
- """Lock file read/write for .processforge/lock.json."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import os
6
- from typing import Any
7
-
8
- LOCK_VERSION = 1
9
- LOCK_FILENAME = "lock.json"
10
-
11
-
12
- def _lock_path(pf_dir: str) -> str:
13
- return os.path.join(pf_dir, LOCK_FILENAME)
14
-
15
-
16
- def read_lock(pf_dir: str) -> dict[str, Any] | None:
17
- """Read lock.json from .processforge/. Returns None if not found."""
18
- path = _lock_path(pf_dir)
19
- if not os.path.exists(path):
20
- return None
21
- with open(path, "r", encoding="utf-8") as f:
22
- return json.load(f)
23
-
24
-
25
- def write_lock(
26
- pf_dir: str,
27
- flowsheet: str,
28
- providers: dict[str, dict],
29
- processforge_version: str,
30
- ) -> None:
31
- """Write lock.json to .processforge/."""
32
- lock = {
33
- "version": LOCK_VERSION,
34
- "processforge_version": processforge_version,
35
- "flowsheet": flowsheet,
36
- "providers": providers,
37
- }
38
- os.makedirs(pf_dir, exist_ok=True)
39
- path = _lock_path(pf_dir)
40
- with open(path, "w", encoding="utf-8") as f:
41
- json.dump(lock, f, indent=2)
File without changes
File without changes
File without changes
File without changes