processforge 0.3.23__tar.gz → 0.3.25__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.
- {processforge-0.3.23/src/processforge.egg-info → processforge-0.3.25}/PKG-INFO +1 -1
- {processforge-0.3.23 → processforge-0.3.25}/flowsheets/hydraulic-chain.json +1 -1
- {processforge-0.3.23 → processforge-0.3.25}/pyproject.toml +1 -1
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/__init__.py +1 -1
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/cli/apply.py +3 -10
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/cli/common.py +1 -1
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/cli/display.py +32 -4
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/cli/init.py +115 -69
- processforge-0.3.25/src/processforge/cli/plan.py +198 -0
- {processforge-0.3.23 → processforge-0.3.25/src/processforge.egg-info}/PKG-INFO +1 -1
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge.egg-info/SOURCES.txt +1 -0
- processforge-0.3.25/tests/test_display.py +36 -0
- processforge-0.3.23/src/processforge/cli/plan.py +0 -141
- {processforge-0.3.23 → processforge-0.3.25}/LICENSE +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/MANIFEST.in +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/README.md +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/flowsheets/closed-loop-chain.json +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/flowsheets/coupled/msre_coupled.json +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/flowsheets/custom/crimson/MUWS_HB.json +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/flowsheets/festim/tds_tungsten.json +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/flowsheets/openmc/msre_eigenvalue.json +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/setup.cfg +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/__main__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/_schema.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/analysis/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/analysis/dof.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/api/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/api/serve.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/cli/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/cli/diagram.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/cli/export_fmu.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/cli/export_modelica.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/cli/run.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/cli/runs.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/cli/validate.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/compose.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/coupling.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/backends/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/backends/base.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/backends/casadi_backend.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/backends/pyomo_backend.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/backends/scipy_backend.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/flowsheet.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/jacobian.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/mixin.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/solver.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/stream_var.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/units/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/units/flash_eo.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/units/heater_eo.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/units/pipes_eo.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/units/pump_eo.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/units/strainer_eo.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/eo/units/valve_eo.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/flowsheet.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/fmu/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/fmu/_fmi_vars.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/fmu/builder.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/fmu/slave_template.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/lock.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/modelica/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/modelica/mo_writer.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/modelica/omc_runner.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/modelica/transpiler.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/modelica/unit_equations.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/output_collector.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/pcl/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/pcl/compiler.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/pcl/namespace.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/persistence/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/persistence/archive.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/persistence/artifact_store.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/provenance.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/base.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/base_jacobian_mixin.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/cantera_jacobian.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/cantera_provider.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/container_client.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/coolprop_provider.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/errors.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/festim_provider.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/jacobian_contributor.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/manager.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/modelica_jacobian.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/modelica_provider.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/openmc_provider.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/reference_state_registry.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/providers/registry.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/quantity.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/result.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/result_schema.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/schemas/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/schemas/festim/festim_model.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/schemas/flowsheet_schema.json +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/schemas/openmc/openmc_model.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/simulate.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/solver.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/state.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/thermo.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/types.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/base.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/cstr.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/flash.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/heater.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/pfr.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/pipes.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/pump.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/registry.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/solver_unit.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/strainer.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/tank.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/units/valve.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/utils/__init__.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/utils/flowsheet_diagram.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/utils/mermaid_diagram.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/utils/s3_upload.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/utils/topology.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/utils/unit_consistency.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/utils/validate_flowsheet.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge/utils/validation.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge.egg-info/dependency_links.txt +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge.egg-info/entry_points.txt +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge.egg-info/requires.txt +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/src/processforge.egg-info/top_level.txt +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_cli.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_container_client.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_coupling.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_festim_provider.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_festim_schema.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_init.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_openmc_provider.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_provider_errors.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_providers.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_result_zarr.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_runs_command.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_s3_upload.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_state.py +0 -0
- {processforge-0.3.23 → processforge-0.3.25}/tests/test_types.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "processforge"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.25"
|
|
8
8
|
description = "A Python-based process simulation framework for chemical engineering applications."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "BSD-3-Clause"
|
|
@@ -63,7 +63,7 @@ from .common import (
|
|
|
63
63
|
write_divergence_report,
|
|
64
64
|
)
|
|
65
65
|
|
|
66
|
-
from .display import print_structural_diff
|
|
66
|
+
from .display import print_param_drift, print_structural_diff
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
def apply(
|
|
@@ -131,16 +131,9 @@ def apply(
|
|
|
131
131
|
if not drifted:
|
|
132
132
|
logger.info("No drift detected. System is already at the desired state.")
|
|
133
133
|
return
|
|
134
|
-
stream_drifts = [d for d in drifted if d.startswith("streams.")]
|
|
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."))]
|
|
137
134
|
logger.warning("Drift detected:")
|
|
138
|
-
if
|
|
139
|
-
|
|
140
|
-
if unit_drifts:
|
|
141
|
-
logger.warning(f" Unit drift : {unit_drifts}")
|
|
142
|
-
if other_drifts:
|
|
143
|
-
logger.warning(f" Config drift : {other_drifts}")
|
|
135
|
+
old_config = state.config if hasattr(state, "config") else state.get("config", {})
|
|
136
|
+
print_param_drift(drifted, old_config, config)
|
|
144
137
|
|
|
145
138
|
# Build flowsheet; attach saved state for warm-start unless topology changed
|
|
146
139
|
fs = EOFlowsheet(config, backend=backend)
|
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
+
import importlib.util
|
|
8
|
+
|
|
7
9
|
from loguru import logger
|
|
8
10
|
|
|
9
11
|
|
|
@@ -53,7 +55,7 @@ def print_unit_mismatches(mismatches: list[Any]) -> None:
|
|
|
53
55
|
logger.warning(f"Unit annotation — stream '{m.stream_name}'.{m.property_name}: {m.message}")
|
|
54
56
|
|
|
55
57
|
|
|
56
|
-
def print_provider_health(config: dict) -> list[str]:
|
|
58
|
+
def print_provider_health(config: dict, strict: bool = False) -> list[str]:
|
|
57
59
|
"""Log per-provider reachability for flowsheets using Docker containers.
|
|
58
60
|
|
|
59
61
|
For each provider declared in *config*:
|
|
@@ -63,10 +65,9 @@ def print_provider_health(config: dict) -> list[str]:
|
|
|
63
65
|
* pip-installable providers are reported as importable or not.
|
|
64
66
|
|
|
65
67
|
Returns a list of failure messages — one per unreachable containerized
|
|
66
|
-
provider (empty means every provider is healthy/available).
|
|
68
|
+
provider (empty means every provider is healthy/available). When *strict*
|
|
69
|
+
is True, a missing pip-installable provider is also appended to failures.
|
|
67
70
|
"""
|
|
68
|
-
import importlib
|
|
69
|
-
|
|
70
71
|
from ..providers.registry import is_containerized, _PROVIDER_CATALOG
|
|
71
72
|
|
|
72
73
|
providers = config.get("providers", {})
|
|
@@ -104,6 +105,10 @@ def print_provider_health(config: dict) -> list[str]:
|
|
|
104
105
|
dep = catalog.get("optional_dep")
|
|
105
106
|
hint = f"pip install 'processforge[{dep}]'" if dep else "built-in"
|
|
106
107
|
logger.warning(f" [WARN] {name} [{ptype}] — not installed ({hint})")
|
|
108
|
+
if strict:
|
|
109
|
+
failures.append(
|
|
110
|
+
f"Provider '{name}' not installed ({hint})"
|
|
111
|
+
)
|
|
107
112
|
|
|
108
113
|
return failures
|
|
109
114
|
|
|
@@ -122,3 +127,26 @@ def print_structural_diff(diff: dict) -> None:
|
|
|
122
127
|
logger.info(f" - {name:<20} [{unit_type}] (removed)")
|
|
123
128
|
if not any(diff.get(k) for k in ("added", "modified", "removed")):
|
|
124
129
|
logger.info(" (no structural changes)")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _resolve_path(cfg: dict, path: str) -> object:
|
|
133
|
+
"""Resolve a dotted config path (e.g. ``simulation.tf``) to its value."""
|
|
134
|
+
cur: object = cfg
|
|
135
|
+
for part in path.split("."):
|
|
136
|
+
if isinstance(cur, dict) and part in cur:
|
|
137
|
+
cur = cur[part]
|
|
138
|
+
else:
|
|
139
|
+
return "<missing>"
|
|
140
|
+
return cur
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def print_param_drift(drifted: list[str], old_config: dict, new_config: dict) -> None:
|
|
144
|
+
"""Log parameter drift paths with their old → new values."""
|
|
145
|
+
logger.info("=== Parameter Drift vs. Saved State ===")
|
|
146
|
+
if not drifted:
|
|
147
|
+
logger.info(" (no parameter changes)")
|
|
148
|
+
return
|
|
149
|
+
for path in drifted:
|
|
150
|
+
old_val = _resolve_path(old_config, path)
|
|
151
|
+
new_val = _resolve_path(new_config, path)
|
|
152
|
+
logger.info(f" ~ {path}: {old_val!r} → {new_val!r}")
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import importlib
|
|
5
|
+
import importlib.util
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
8
|
import shutil
|
|
@@ -11,8 +11,18 @@ import subprocess
|
|
|
11
11
|
import typer
|
|
12
12
|
from loguru import logger
|
|
13
13
|
|
|
14
|
-
from .common import
|
|
15
|
-
|
|
14
|
+
from .common import (
|
|
15
|
+
extract_providers,
|
|
16
|
+
is_local_provider_url,
|
|
17
|
+
)
|
|
18
|
+
from ..lock import flowsheet_env_dir, read_lock, write_lock
|
|
19
|
+
from ..providers.registry import (
|
|
20
|
+
get_provider_docker_image,
|
|
21
|
+
get_provider_default_port,
|
|
22
|
+
is_containerized,
|
|
23
|
+
_PROVIDER_CATALOG,
|
|
24
|
+
)
|
|
25
|
+
from ..utils.validate_flowsheet import validate_flowsheet
|
|
16
26
|
|
|
17
27
|
|
|
18
28
|
def _migrate_legacy_env(pf_dir: str) -> None:
|
|
@@ -21,7 +31,9 @@ def _migrate_legacy_env(pf_dir: str) -> None:
|
|
|
21
31
|
Older processforge stored a single environment at ``.processforge/lock.json``
|
|
22
32
|
and ``.processforge/docker-compose.yml``. This relocates those into the hashed
|
|
23
33
|
env dir derived from the flowsheet recorded in the legacy lock, so an existing
|
|
24
|
-
repo keeps its provider environment after upgrading.
|
|
34
|
+
repo keeps its provider environment after upgrading. The target always uses the
|
|
35
|
+
same hashed env dir that ``pf init``/``read_lock`` locate, so the migrated env
|
|
36
|
+
is never orphaned.
|
|
25
37
|
"""
|
|
26
38
|
legacy_lock = os.path.join(pf_dir, "lock.json")
|
|
27
39
|
legacy_compose = os.path.join(pf_dir, "docker-compose.yml")
|
|
@@ -35,15 +47,19 @@ def _migrate_legacy_env(pf_dir: str) -> None:
|
|
|
35
47
|
except Exception:
|
|
36
48
|
recorded = None
|
|
37
49
|
|
|
38
|
-
|
|
39
|
-
|
|
50
|
+
# Always target the hashed env dir keyed by the recorded flowsheet path so it
|
|
51
|
+
# matches what `pf init <flowsheet>` / `read_lock` expect on subsequent runs.
|
|
52
|
+
env_dir = flowsheet_env_dir(pf_dir, recorded or "legacy")
|
|
53
|
+
if recorded:
|
|
54
|
+
logger.info(
|
|
55
|
+
f"Migrating legacy .processforge/lock.json + docker-compose.yml into "
|
|
56
|
+
f"'{os.path.relpath(env_dir, pf_dir)}/'."
|
|
57
|
+
)
|
|
40
58
|
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
59
|
logger.warning(
|
|
44
60
|
"Migrating legacy .processforge/lock.json + docker-compose.yml into "
|
|
45
|
-
f"'{os.path.relpath(env_dir, pf_dir)}/' (recorded flowsheet
|
|
46
|
-
"
|
|
61
|
+
f"'{os.path.relpath(env_dir, pf_dir)}/' (no recorded flowsheet found — "
|
|
62
|
+
"re-run `pf init <flowsheet.json>` to restore the correct env dir)."
|
|
47
63
|
)
|
|
48
64
|
|
|
49
65
|
if os.path.exists(env_dir):
|
|
@@ -73,15 +89,19 @@ def init(
|
|
|
73
89
|
"--path",
|
|
74
90
|
help="Root directory to initialise in (default: current directory)",
|
|
75
91
|
),
|
|
92
|
+
no_pull: bool = typer.Option(
|
|
93
|
+
False,
|
|
94
|
+
"--no-pull",
|
|
95
|
+
help="Generate docker-compose.yml but skip pulling provider images",
|
|
96
|
+
),
|
|
97
|
+
force: bool = typer.Option(
|
|
98
|
+
False,
|
|
99
|
+
"--force",
|
|
100
|
+
"-f",
|
|
101
|
+
help="Re-initialise even if the environment already exists",
|
|
102
|
+
),
|
|
76
103
|
) -> None:
|
|
77
104
|
"""Initialise the .processforge/ project directory."""
|
|
78
|
-
from ..providers.registry import (
|
|
79
|
-
is_containerized,
|
|
80
|
-
get_provider_docker_image,
|
|
81
|
-
get_provider_default_port,
|
|
82
|
-
_PROVIDER_CATALOG,
|
|
83
|
-
)
|
|
84
|
-
from ..lock import write_lock
|
|
85
105
|
from ..compose import generate_compose
|
|
86
106
|
|
|
87
107
|
root = path or "."
|
|
@@ -96,17 +116,6 @@ def init(
|
|
|
96
116
|
# dir so existing repos aren't silently broken by the new structure.
|
|
97
117
|
_migrate_legacy_env(pf_dir)
|
|
98
118
|
|
|
99
|
-
# Remove stale .pfstate snapshot directories from outputs/
|
|
100
|
-
stale_count = 0
|
|
101
|
-
for entry in os.listdir(outputs_dir):
|
|
102
|
-
if entry.endswith(".pfstate"):
|
|
103
|
-
stale = os.path.join(outputs_dir, entry)
|
|
104
|
-
if os.path.isdir(stale):
|
|
105
|
-
shutil.rmtree(stale)
|
|
106
|
-
stale_count += 1
|
|
107
|
-
if stale_count:
|
|
108
|
-
logger.info(f"Removed {stale_count} stale snapshot(s) from {outputs_dir}/.")
|
|
109
|
-
|
|
110
119
|
# Write config.json (always)
|
|
111
120
|
config_path = os.path.join(pf_dir, "config.json")
|
|
112
121
|
if not os.path.exists(config_path):
|
|
@@ -121,6 +130,26 @@ def init(
|
|
|
121
130
|
else:
|
|
122
131
|
logger.info(f"{config_path} already exists — skipped.")
|
|
123
132
|
|
|
133
|
+
# Honour a configured outputs_dir for the rest of init.
|
|
134
|
+
outputs_dir_name = "outputs"
|
|
135
|
+
try:
|
|
136
|
+
with open(config_path, encoding="utf-8") as f:
|
|
137
|
+
outputs_dir_name = json.load(f).get("outputs_dir", "outputs")
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
outputs_dir = os.path.join(root, outputs_dir_name)
|
|
141
|
+
|
|
142
|
+
# Remove stale .pfstate snapshot directories from outputs/
|
|
143
|
+
stale_count = 0
|
|
144
|
+
for entry in os.listdir(outputs_dir):
|
|
145
|
+
if entry.endswith(".pfstate"):
|
|
146
|
+
stale = os.path.join(outputs_dir, entry)
|
|
147
|
+
if os.path.isdir(stale):
|
|
148
|
+
shutil.rmtree(stale)
|
|
149
|
+
stale_count += 1
|
|
150
|
+
if stale_count:
|
|
151
|
+
logger.info(f"Removed {stale_count} stale snapshot(s) from {outputs_dir}/.")
|
|
152
|
+
|
|
124
153
|
# No flowsheet → scaffold only
|
|
125
154
|
if not flowsheet:
|
|
126
155
|
logger.info(".processforge/ initialised successfully.")
|
|
@@ -131,15 +160,21 @@ def init(
|
|
|
131
160
|
flowsheet_path = flowsheet
|
|
132
161
|
if not os.path.exists(flowsheet_path):
|
|
133
162
|
logger.error(f"Flowsheet '{flowsheet_path}' not found.")
|
|
134
|
-
raise
|
|
163
|
+
raise typer.Exit(code=1)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
validate_flowsheet(flowsheet_path)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error(f"Failed to validate flowsheet '{flowsheet_path}': {e}")
|
|
169
|
+
raise typer.Exit(code=1)
|
|
135
170
|
|
|
136
171
|
providers = extract_providers(flowsheet_path)
|
|
137
172
|
logger.info(f"Reading providers from {flowsheet_path}...")
|
|
138
173
|
|
|
139
|
-
# Categorize providers
|
|
140
|
-
pip_providers: dict[str, dict] = {}
|
|
174
|
+
# Categorize providers, reusing the shared local/remote/pip classification.
|
|
141
175
|
local_docker_providers: dict[str, dict] = {}
|
|
142
176
|
remote_docker_providers: dict[str, dict] = {}
|
|
177
|
+
pip_providers: dict[str, dict] = {}
|
|
143
178
|
for name, cfg in providers.items():
|
|
144
179
|
ptype = cfg.get("type", "")
|
|
145
180
|
if is_containerized(ptype):
|
|
@@ -151,7 +186,8 @@ def init(
|
|
|
151
186
|
local_docker_providers[name] = {
|
|
152
187
|
"type": ptype,
|
|
153
188
|
"url": url,
|
|
154
|
-
"docker_image": cfg.get("docker_image")
|
|
189
|
+
"docker_image": cfg.get("docker_image")
|
|
190
|
+
or get_provider_docker_image(ptype),
|
|
155
191
|
"port": port,
|
|
156
192
|
}
|
|
157
193
|
logger.info(f" {name}: type={ptype}, url={url} (Docker, local)")
|
|
@@ -159,7 +195,8 @@ def init(
|
|
|
159
195
|
remote_docker_providers[name] = {
|
|
160
196
|
"type": ptype,
|
|
161
197
|
"url": url,
|
|
162
|
-
"docker_image": cfg.get("docker_image")
|
|
198
|
+
"docker_image": cfg.get("docker_image")
|
|
199
|
+
or get_provider_docker_image(ptype),
|
|
163
200
|
}
|
|
164
201
|
logger.info(
|
|
165
202
|
f" {name}: type={ptype}, url={url} (Docker, remote — skipping compose)"
|
|
@@ -185,45 +222,48 @@ def init(
|
|
|
185
222
|
# Providers with an explicit remote URL are assumed to be running elsewhere
|
|
186
223
|
# (e.g. a cloud deployment of the ghcr.io image) and are not touched here.
|
|
187
224
|
if local_docker_providers:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
)
|
|
191
|
-
if os.path.exists(compose_path):
|
|
225
|
+
env_dir = flowsheet_env_dir(pf_dir, flowsheet_path)
|
|
226
|
+
compose_path = os.path.join(env_dir, "docker-compose.yml")
|
|
227
|
+
if os.path.exists(compose_path) and not force:
|
|
192
228
|
logger.warning(
|
|
193
229
|
f"Environment already initialized — reinitializing from {flowsheet_path}"
|
|
194
230
|
)
|
|
195
231
|
|
|
196
|
-
generate_compose(
|
|
232
|
+
generate_compose(
|
|
233
|
+
pf_dir, local_docker_providers, outputs_dir, flowsheet=flowsheet_path
|
|
234
|
+
)
|
|
197
235
|
logger.info(f"Generated {compose_path}")
|
|
198
236
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
237
|
+
if no_pull:
|
|
238
|
+
logger.info("Skipping Docker image pull (--no-pull).")
|
|
239
|
+
else:
|
|
240
|
+
# Attempt docker compose pull. Capture output and stream it via the
|
|
241
|
+
# logger; on timeout the child is reaped (subprocess.run kills it)
|
|
242
|
+
# so no orphaned/blocked process is left behind.
|
|
243
|
+
try:
|
|
244
|
+
logger.info("Pulling Docker images (this may take a while)...")
|
|
245
|
+
result = subprocess.run(
|
|
246
|
+
["docker", "compose", "-f", compose_path, "pull"],
|
|
247
|
+
stdout=subprocess.PIPE,
|
|
248
|
+
stderr=subprocess.STDOUT,
|
|
249
|
+
text=True,
|
|
250
|
+
timeout=600,
|
|
251
|
+
)
|
|
252
|
+
for line in result.stdout.splitlines():
|
|
253
|
+
if line.strip():
|
|
254
|
+
logger.info(line)
|
|
255
|
+
if result.returncode == 0:
|
|
256
|
+
logger.info("Pulled Docker images.")
|
|
257
|
+
else:
|
|
258
|
+
logger.warning(
|
|
259
|
+
f"docker compose pull failed with exit code {result.returncode}."
|
|
260
|
+
)
|
|
261
|
+
except FileNotFoundError:
|
|
218
262
|
logger.warning(
|
|
219
|
-
|
|
263
|
+
"Docker not found. Install Docker to use containerized providers."
|
|
220
264
|
)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
"Docker not found. Install Docker to use containerized providers."
|
|
224
|
-
)
|
|
225
|
-
except subprocess.TimeoutExpired:
|
|
226
|
-
logger.warning("docker compose pull timed out after 600s.")
|
|
265
|
+
except subprocess.TimeoutExpired:
|
|
266
|
+
logger.warning("docker compose pull timed out after 600s.")
|
|
227
267
|
|
|
228
268
|
logger.info(
|
|
229
269
|
"To start the containerized provider(s), run:\n"
|
|
@@ -242,15 +282,19 @@ def init(
|
|
|
242
282
|
else:
|
|
243
283
|
logger.info("No containerized providers — skipping Docker setup.")
|
|
244
284
|
|
|
245
|
-
# Write lock file
|
|
285
|
+
# Write lock file, reusing the shared URL resolution for the recorded url.
|
|
246
286
|
lock_providers: dict[str, dict] = {}
|
|
247
287
|
for name, cfg in providers.items():
|
|
248
288
|
ptype = cfg.get("type", "")
|
|
249
289
|
if is_containerized(ptype):
|
|
290
|
+
url = cfg.get("url")
|
|
291
|
+
if not url:
|
|
292
|
+
port = get_provider_default_port(ptype) or 9000
|
|
293
|
+
url = f"http://localhost:{port}"
|
|
250
294
|
lock_providers[name] = {
|
|
251
|
-
"docker_image": cfg.get("docker_image")
|
|
252
|
-
|
|
253
|
-
|
|
295
|
+
"docker_image": cfg.get("docker_image")
|
|
296
|
+
or get_provider_docker_image(ptype),
|
|
297
|
+
"url": url,
|
|
254
298
|
}
|
|
255
299
|
else:
|
|
256
300
|
lock_providers[name] = {
|
|
@@ -261,5 +305,7 @@ def init(
|
|
|
261
305
|
from .. import __version__ as pf_version
|
|
262
306
|
|
|
263
307
|
write_lock(pf_dir, flowsheet_path, lock_providers, pf_version)
|
|
264
|
-
logger.info(
|
|
308
|
+
logger.info(
|
|
309
|
+
f"Wrote {os.path.join(flowsheet_env_dir(pf_dir, flowsheet_path), 'lock.json')}"
|
|
310
|
+
)
|
|
265
311
|
logger.info(".processforge/ initialised successfully.")
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""``pf plan`` — validate, DOF analysis, structural diff, and Mermaid diagram."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from ..analysis.dof import analyze_dof
|
|
12
|
+
from ..utils.validate_flowsheet import validate_flowsheet_dict
|
|
13
|
+
from ..utils.unit_consistency import check_unit_consistency, strip_units_annotations
|
|
14
|
+
from ..pcl import load_pcl, PCLCompileError
|
|
15
|
+
from ..utils.mermaid_diagram import generate_mermaid
|
|
16
|
+
from .common import (
|
|
17
|
+
flowsheet_basename,
|
|
18
|
+
load_state_manager,
|
|
19
|
+
output_root,
|
|
20
|
+
require_existing_file,
|
|
21
|
+
validate_snapshot_config,
|
|
22
|
+
)
|
|
23
|
+
from .display import (
|
|
24
|
+
print_dof_report,
|
|
25
|
+
print_param_drift,
|
|
26
|
+
print_provider_health,
|
|
27
|
+
print_structural_diff,
|
|
28
|
+
print_unit_mismatches,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _find_processforge_root(start: str) -> str | None:
|
|
33
|
+
"""Walk up from *start*'s directory to find the dir holding `.processforge`."""
|
|
34
|
+
d = os.path.dirname(os.path.abspath(start))
|
|
35
|
+
while True:
|
|
36
|
+
if os.path.isdir(os.path.join(d, ".processforge")):
|
|
37
|
+
return d
|
|
38
|
+
parent = os.path.dirname(d)
|
|
39
|
+
if parent == d:
|
|
40
|
+
return None
|
|
41
|
+
d = parent
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def plan(
|
|
45
|
+
flowsheet: str = typer.Argument(help="Path to .pcl or .json flowsheet file"),
|
|
46
|
+
output_dir: str = typer.Option(
|
|
47
|
+
"diagrams",
|
|
48
|
+
"--output-dir",
|
|
49
|
+
"-o",
|
|
50
|
+
help="Output directory for the Mermaid diagram (default: diagrams)",
|
|
51
|
+
),
|
|
52
|
+
no_diagram: bool = typer.Option(
|
|
53
|
+
False,
|
|
54
|
+
"--no-diagram",
|
|
55
|
+
help="Skip Mermaid diagram generation",
|
|
56
|
+
),
|
|
57
|
+
no_dof: bool = typer.Option(
|
|
58
|
+
False,
|
|
59
|
+
"--no-dof",
|
|
60
|
+
help="Skip Degrees-of-Freedom analysis",
|
|
61
|
+
),
|
|
62
|
+
no_health: bool = typer.Option(
|
|
63
|
+
False,
|
|
64
|
+
"--no-health",
|
|
65
|
+
help="Skip provider/container health check (faster, for CI/quick plans)",
|
|
66
|
+
),
|
|
67
|
+
strict: bool = typer.Option(
|
|
68
|
+
False,
|
|
69
|
+
"--strict",
|
|
70
|
+
help="Treat missing pip providers as hard failures (in addition to unreachable containers)",
|
|
71
|
+
),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Parse a PCL or JSON flowsheet, run validators, DOF analysis, structural diff, and emit a Mermaid diagram."""
|
|
74
|
+
require_existing_file(flowsheet, label="File")
|
|
75
|
+
|
|
76
|
+
# Step 1: Load config
|
|
77
|
+
if flowsheet.endswith(".pcl"):
|
|
78
|
+
try:
|
|
79
|
+
json_config = load_pcl(flowsheet)
|
|
80
|
+
except PCLCompileError as e:
|
|
81
|
+
logger.error(f"PCL compile error: {e}")
|
|
82
|
+
raise typer.Exit(code=1)
|
|
83
|
+
else:
|
|
84
|
+
try:
|
|
85
|
+
with open(flowsheet, "r", encoding="utf-8") as f:
|
|
86
|
+
json_config = json.load(f)
|
|
87
|
+
except json.JSONDecodeError as e:
|
|
88
|
+
logger.error(f"JSON parse error in '{flowsheet}': {e}")
|
|
89
|
+
raise typer.Exit(code=1)
|
|
90
|
+
except OSError as e:
|
|
91
|
+
logger.error(f"Failed to read '{flowsheet}': {e}")
|
|
92
|
+
raise typer.Exit(code=1)
|
|
93
|
+
|
|
94
|
+
# Step 2: Pint unit consistency check (before _units are stripped)
|
|
95
|
+
mismatches = check_unit_consistency(json_config)
|
|
96
|
+
print_unit_mismatches(mismatches)
|
|
97
|
+
|
|
98
|
+
# Step 3: Strip _units annotations before schema validation
|
|
99
|
+
strip_units_annotations(json_config)
|
|
100
|
+
|
|
101
|
+
# Step 4: Schema + connectivity validation
|
|
102
|
+
try:
|
|
103
|
+
config = validate_flowsheet_dict(json_config, source_name=flowsheet)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.error(f"Validation failed: {type(e).__name__}: {e}")
|
|
106
|
+
raise typer.Exit(code=1)
|
|
107
|
+
|
|
108
|
+
# Step 5: DOF analysis
|
|
109
|
+
if no_dof:
|
|
110
|
+
logger.info("=== Degrees of Freedom Analysis ===")
|
|
111
|
+
logger.info(" Skipped (--no-dof).")
|
|
112
|
+
else:
|
|
113
|
+
report = analyze_dof(config)
|
|
114
|
+
print_dof_report(report)
|
|
115
|
+
|
|
116
|
+
# Step 5b: DOF fix suggestions
|
|
117
|
+
if report.system_dof != 0:
|
|
118
|
+
logger.warning("=== DOF Fix Suggestions ===")
|
|
119
|
+
for r in report.per_unit:
|
|
120
|
+
if r.dof > 0:
|
|
121
|
+
for issue in r.issues:
|
|
122
|
+
logger.warning(
|
|
123
|
+
f" Unit '{r.unit_name}' [{r.unit_type}]: {issue}"
|
|
124
|
+
)
|
|
125
|
+
elif r.dof < 0:
|
|
126
|
+
logger.warning(
|
|
127
|
+
f" Unit '{r.unit_name}' [{r.unit_type}]: "
|
|
128
|
+
f"possibly over-specified by {abs(r.dof)} equation(s)"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Step 6: Structural diff vs. saved state
|
|
132
|
+
base_name = flowsheet_basename(flowsheet)
|
|
133
|
+
outputs_dir = output_root()
|
|
134
|
+
sm, state = load_state_manager(outputs_dir, base_name)
|
|
135
|
+
diff = None
|
|
136
|
+
if state is not None:
|
|
137
|
+
validate_snapshot_config(state, base_name)
|
|
138
|
+
diff = sm.detect_structural_diff(config, state)
|
|
139
|
+
print_structural_diff(diff)
|
|
140
|
+
# Step 6b: Parameter drift (non-structural value changes)
|
|
141
|
+
old_config = state.config
|
|
142
|
+
drifted = sm.detect_drift(config, state)
|
|
143
|
+
print_param_drift(drifted, old_config, config)
|
|
144
|
+
else:
|
|
145
|
+
logger.info("=== Structural Diff vs. Saved State ===")
|
|
146
|
+
logger.info(" No prior state found — this will be a cold start.")
|
|
147
|
+
|
|
148
|
+
# Step 7: Warm-start and homotopy eligibility
|
|
149
|
+
logger.info("=== Warm-Start Status ===")
|
|
150
|
+
if state is not None:
|
|
151
|
+
snap_id = state.snapshot_id
|
|
152
|
+
snap_ts = state.timestamp
|
|
153
|
+
logger.info(f" Warm-start available : Yes (snapshot {snap_id}, {snap_ts})")
|
|
154
|
+
topology_ok = diff is None or not bool(diff.get("added") or diff.get("removed"))
|
|
155
|
+
logger.info(
|
|
156
|
+
f" Homotopy eligible : {'Yes' if topology_ok else 'No (topology changed)'}"
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
logger.info(" Warm-start available : No snapshot found")
|
|
160
|
+
logger.info(" Homotopy eligible : No (cold start)")
|
|
161
|
+
|
|
162
|
+
# Step 8: Provider / container health check
|
|
163
|
+
health_failures: list[str] = []
|
|
164
|
+
if no_health:
|
|
165
|
+
logger.info("=== Provider / Container Health ===")
|
|
166
|
+
logger.info(" Skipped (--no-health).")
|
|
167
|
+
else:
|
|
168
|
+
health_failures = print_provider_health(config, strict=strict)
|
|
169
|
+
|
|
170
|
+
# Step 9: Mermaid diagram
|
|
171
|
+
if not no_diagram:
|
|
172
|
+
diagram_output_dir = output_dir or "diagrams"
|
|
173
|
+
os.makedirs(diagram_output_dir, exist_ok=True)
|
|
174
|
+
out_path = os.path.join(diagram_output_dir, f"{base_name}_plan.mmd")
|
|
175
|
+
try:
|
|
176
|
+
src = generate_mermaid(config)
|
|
177
|
+
existing = None
|
|
178
|
+
if os.path.exists(out_path):
|
|
179
|
+
try:
|
|
180
|
+
with open(out_path, encoding="utf-8") as f:
|
|
181
|
+
existing = f.read()
|
|
182
|
+
except OSError:
|
|
183
|
+
existing = None
|
|
184
|
+
if existing == src:
|
|
185
|
+
logger.info(f"Mermaid diagram unchanged: {out_path}")
|
|
186
|
+
else:
|
|
187
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
188
|
+
f.write(src)
|
|
189
|
+
logger.info(f"Mermaid diagram -> {out_path}")
|
|
190
|
+
except Exception as e:
|
|
191
|
+
logger.warning(
|
|
192
|
+
f"Failed to generate Mermaid diagram: {type(e).__name__}: {e}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Exit non-zero on hard errors
|
|
196
|
+
hard_errors = [m for m in mismatches if not m.compatible]
|
|
197
|
+
if hard_errors or (not no_dof and report.system_dof < 0) or health_failures:
|
|
198
|
+
raise typer.Exit(code=1)
|