processforge 0.2.38__tar.gz → 0.2.40__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of processforge might be problematic. Click here for more details.
- {processforge-0.2.38/src/processforge.egg-info → processforge-0.2.40}/PKG-INFO +2 -1
- {processforge-0.2.38 → processforge-0.2.40}/pyproject.toml +2 -1
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/__init__.py +1 -1
- processforge-0.2.40/src/processforge/cli/__init__.py +44 -0
- processforge-0.2.40/src/processforge/cli/apply.py +207 -0
- processforge-0.2.40/src/processforge/cli/common.py +280 -0
- processforge-0.2.40/src/processforge/cli/diagram.py +57 -0
- processforge-0.2.40/src/processforge/cli/display.py +69 -0
- processforge-0.2.40/src/processforge/cli/export_fmu.py +41 -0
- processforge-0.2.40/src/processforge/cli/export_modelica.py +50 -0
- processforge-0.2.40/src/processforge/cli/init.py +171 -0
- processforge-0.2.40/src/processforge/cli/plan.py +132 -0
- processforge-0.2.40/src/processforge/cli/run.py +106 -0
- processforge-0.2.40/src/processforge/cli/validate.py +20 -0
- processforge-0.2.40/src/processforge/simulate.py +59 -0
- {processforge-0.2.38 → processforge-0.2.40/src/processforge.egg-info}/PKG-INFO +2 -1
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge.egg-info/SOURCES.txt +12 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge.egg-info/requires.txt +1 -0
- processforge-0.2.40/tests/test_cli.py +380 -0
- {processforge-0.2.38 → processforge-0.2.40}/tests/test_init.py +9 -9
- processforge-0.2.38/src/processforge/simulate.py +0 -930
- {processforge-0.2.38 → processforge-0.2.40}/LICENSE +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/MANIFEST.in +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/README.md +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/flowsheets/closed-loop-chain.json +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/flowsheets/hydraulic-chain.json +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/flowsheets/openmc/msre_eigenvalue.json +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/setup.cfg +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/__main__.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/_schema.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/analysis/__init__.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/analysis/dof.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/compose.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/__init__.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/backends/__init__.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/backends/base.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/backends/casadi_backend.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/backends/pyomo_backend.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/backends/scipy_backend.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/flowsheet.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/jacobian.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/mixin.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/solver.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/stream_var.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/units/__init__.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/units/flash_eo.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/units/heater_eo.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/units/pipes_eo.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/units/pump_eo.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/units/strainer_eo.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/eo/units/valve_eo.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/flowsheet.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/fmu/__init__.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/fmu/_fmi_vars.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/fmu/builder.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/fmu/slave_template.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/lock.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/modelica/__init__.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/modelica/mo_writer.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/modelica/omc_runner.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/modelica/transpiler.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/modelica/unit_equations.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/pcl/__init__.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/pcl/compiler.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/pcl/namespace.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/provenance.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/__init__.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/base.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/base_jacobian_mixin.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/cantera_jacobian.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/cantera_provider.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/coolprop_provider.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/festim_provider.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/jacobian_contributor.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/manager.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/modelica_jacobian.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/modelica_provider.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/openmc_provider.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/reference_state_registry.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/providers/registry.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/result.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/schemas/__init__.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/schemas/flowsheet_schema.json +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/schemas/openmc/openmc_model.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/solver.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/state.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/thermo.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/types.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/__init__.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/cstr.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/flash.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/heater.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/pfr.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/pipes.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/provider_mixin.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/pump.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/registry.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/solver.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/solver_unit.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/strainer.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/tank.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/units/valve.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/utils/__init__.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/utils/flowsheet_diagram.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/utils/mermaid_diagram.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/utils/topology.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/utils/unit_consistency.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/utils/validate_flowsheet.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge/utils/validation.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge.egg-info/dependency_links.txt +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge.egg-info/entry_points.txt +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/src/processforge.egg-info/top_level.txt +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/tests/test_festim_provider.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/tests/test_providers.py +0 -0
- {processforge-0.2.38 → processforge-0.2.40}/tests/test_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: processforge
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.40
|
|
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
|
|
@@ -29,6 +29,7 @@ Requires-Dist: graphviz>=0.20
|
|
|
29
29
|
Requires-Dist: pandas>=2.2
|
|
30
30
|
Requires-Dist: zarr>=3.0
|
|
31
31
|
Requires-Dist: coolprop>=7.2.0
|
|
32
|
+
Requires-Dist: typer>=0.12
|
|
32
33
|
Provides-Extra: eo
|
|
33
34
|
Requires-Dist: pyomo>=6.7; extra == "eo"
|
|
34
35
|
Provides-Extra: eo-casadi
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "processforge"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.40"
|
|
8
8
|
description = "A Python-based process simulation framework for chemical engineering applications."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "BSD-3-Clause"
|
|
@@ -35,6 +35,7 @@ dependencies = [
|
|
|
35
35
|
"pandas>=2.2",
|
|
36
36
|
"zarr>=3.0",
|
|
37
37
|
"coolprop>=7.2.0",
|
|
38
|
+
"typer>=0.12",
|
|
38
39
|
]
|
|
39
40
|
|
|
40
41
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CLI subpackage — one module per ``pf`` subcommand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def register_commands(app: typer.Typer) -> None:
|
|
9
|
+
"""Register all subcommands on *app*."""
|
|
10
|
+
from .init import init
|
|
11
|
+
from .validate import validate
|
|
12
|
+
from .run import run
|
|
13
|
+
from .apply import apply
|
|
14
|
+
from .plan import plan
|
|
15
|
+
from .diagram import diagram
|
|
16
|
+
from .export_fmu import export_fmu
|
|
17
|
+
from .export_modelica import export_modelica
|
|
18
|
+
|
|
19
|
+
app.command(
|
|
20
|
+
"init",
|
|
21
|
+
help="Initialise .processforge/ project directory and provider environment",
|
|
22
|
+
)(init)
|
|
23
|
+
app.command(
|
|
24
|
+
"validate",
|
|
25
|
+
help="Check providers and environment are ready for a flowsheet",
|
|
26
|
+
)(validate)
|
|
27
|
+
app.command("run", help="Run a process simulation")(run)
|
|
28
|
+
app.command(
|
|
29
|
+
"apply",
|
|
30
|
+
help="Apply flowsheet using state-based warm start and homotopy fallback",
|
|
31
|
+
)(apply)
|
|
32
|
+
app.command(
|
|
33
|
+
"plan",
|
|
34
|
+
help="Validate a flowsheet, run DOF analysis, structural diff, and generate a Mermaid diagram",
|
|
35
|
+
)(plan)
|
|
36
|
+
app.command("diagram", help="Generate a flowsheet diagram")(diagram)
|
|
37
|
+
app.command(
|
|
38
|
+
"export-modelica",
|
|
39
|
+
help="Transpile flowsheet to Modelica .mo and compile via OMPython",
|
|
40
|
+
)(export_modelica)
|
|
41
|
+
app.command(
|
|
42
|
+
"export-fmu",
|
|
43
|
+
help="Export flowsheet as FMI 2.0 co-simulation FMU",
|
|
44
|
+
)(export_fmu)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""``pf apply`` — state-based warm start with homotopy fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from ..eo import EOFlowsheet
|
|
13
|
+
from ..provenance import build_run_info
|
|
14
|
+
from ..result import save_results_zarr
|
|
15
|
+
from ..state import StateManager
|
|
16
|
+
from .common import (
|
|
17
|
+
build_divergence_report,
|
|
18
|
+
build_run_metadata,
|
|
19
|
+
check_providers,
|
|
20
|
+
load_state_manager,
|
|
21
|
+
log_residual_breakdown,
|
|
22
|
+
output_root,
|
|
23
|
+
require_existing_file,
|
|
24
|
+
save_snapshot,
|
|
25
|
+
validate_runtime_flowsheet,
|
|
26
|
+
write_divergence_report,
|
|
27
|
+
)
|
|
28
|
+
from .display import print_structural_diff
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def apply(
|
|
32
|
+
flowsheet: str = typer.Argument(help="Path to the flowsheet JSON file"),
|
|
33
|
+
backend: Literal["scipy", "pyomo", "casadi"] | None = typer.Option(
|
|
34
|
+
None,
|
|
35
|
+
"--backend",
|
|
36
|
+
help="Override the flowsheet's simulation.backend",
|
|
37
|
+
),
|
|
38
|
+
tolerance: float = typer.Option(
|
|
39
|
+
1e-6,
|
|
40
|
+
"--tolerance",
|
|
41
|
+
help="Newton solver convergence tolerance (default: 1e-6)",
|
|
42
|
+
),
|
|
43
|
+
max_iter: int = typer.Option(
|
|
44
|
+
50,
|
|
45
|
+
"--max-iter",
|
|
46
|
+
help="Max Newton iterations (default: 50)",
|
|
47
|
+
),
|
|
48
|
+
skip_homotopy: bool = typer.Option(
|
|
49
|
+
False,
|
|
50
|
+
"--skip-homotopy",
|
|
51
|
+
help="Disable homotopy fallback; cold-start only",
|
|
52
|
+
),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Apply flowsheet: drift detection, warm-start, homotopy fallback, convergence guardrails."""
|
|
55
|
+
require_existing_file(flowsheet)
|
|
56
|
+
config = validate_runtime_flowsheet(flowsheet)
|
|
57
|
+
|
|
58
|
+
# Check provider availability
|
|
59
|
+
check_providers(config, flowsheet)
|
|
60
|
+
|
|
61
|
+
base_name = os.path.splitext(os.path.basename(flowsheet))[0]
|
|
62
|
+
|
|
63
|
+
sim_cfg = config.get("simulation", {})
|
|
64
|
+
mode = sim_cfg.get("mode", "steady")
|
|
65
|
+
if mode != "steady":
|
|
66
|
+
logger.error("pf apply is only supported for steady-state EO flowsheets.")
|
|
67
|
+
raise SystemExit(1)
|
|
68
|
+
|
|
69
|
+
outputs_dir = output_root()
|
|
70
|
+
os.makedirs(outputs_dir, exist_ok=True)
|
|
71
|
+
sm, state = load_state_manager(outputs_dir, base_name)
|
|
72
|
+
|
|
73
|
+
# Structural diff: detect topology changes
|
|
74
|
+
topology_changed = False
|
|
75
|
+
if state is not None:
|
|
76
|
+
diff = sm.detect_structural_diff(config, state)
|
|
77
|
+
print_structural_diff(diff)
|
|
78
|
+
topology_changed = bool(diff.get("added") or diff.get("removed"))
|
|
79
|
+
if topology_changed:
|
|
80
|
+
logger.warning(
|
|
81
|
+
"Topology changed (units added/removed). "
|
|
82
|
+
"Homotopy requires identical topology — falling back to cold start."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Parameter drift (only meaningful when topology is unchanged)
|
|
86
|
+
drifted: list[str] = []
|
|
87
|
+
current_metadata = build_run_metadata(config, tolerance, max_iter, backend or "scipy")
|
|
88
|
+
if state is not None and not topology_changed:
|
|
89
|
+
mismatches = sm.validate_metadata(current_metadata, state)
|
|
90
|
+
if mismatches:
|
|
91
|
+
logger.warning(f"Metadata mismatch: {mismatches}")
|
|
92
|
+
drifted = sm.detect_drift(config, state)
|
|
93
|
+
if not drifted:
|
|
94
|
+
logger.info("No drift detected. System is already at the desired state.")
|
|
95
|
+
return
|
|
96
|
+
stream_drifts = [d for d in drifted if d.startswith("streams.")]
|
|
97
|
+
unit_drifts = [d for d in drifted if d.startswith("units.")]
|
|
98
|
+
logger.warning("Drift detected:")
|
|
99
|
+
if stream_drifts:
|
|
100
|
+
logger.warning(f" Stream drift : {stream_drifts}")
|
|
101
|
+
if unit_drifts:
|
|
102
|
+
logger.warning(f" Unit drift : {unit_drifts}")
|
|
103
|
+
|
|
104
|
+
# Build flowsheet; attach saved state for warm-start unless topology changed
|
|
105
|
+
fs = EOFlowsheet(config, backend=backend)
|
|
106
|
+
fs.saved_state = state if not topology_changed else None
|
|
107
|
+
fs.solver_tol = tolerance
|
|
108
|
+
fs.solver_max_iter = max_iter
|
|
109
|
+
|
|
110
|
+
logger.info("=== Running Apply (Steady-State EO) ===")
|
|
111
|
+
t0 = time.perf_counter()
|
|
112
|
+
results = fs.run()
|
|
113
|
+
elapsed = time.perf_counter() - t0
|
|
114
|
+
|
|
115
|
+
logger.info(
|
|
116
|
+
f"Direct solve completed in {elapsed:.2f}s (converged={fs.converged})."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if fs.converged:
|
|
120
|
+
snapshot_id = save_snapshot(
|
|
121
|
+
sm, config, fs.x_converged, fs.var_names,
|
|
122
|
+
metadata=current_metadata,
|
|
123
|
+
parent_snapshot_id=state.snapshot_id if state is not None and not topology_changed else None,
|
|
124
|
+
label="converged state",
|
|
125
|
+
)
|
|
126
|
+
run_info = build_run_info(config, x0=fs.x0, var_names=fs.var_names)
|
|
127
|
+
zarr_path = os.path.join(outputs_dir, f"{base_name}_results.zarr")
|
|
128
|
+
save_results_zarr(results, zarr_path, run_info=run_info)
|
|
129
|
+
logger.info("=== Apply Summary ===")
|
|
130
|
+
logger.info(" Status : CONVERGED")
|
|
131
|
+
logger.info(f" Final ||F|| : {fs.solver_stats.get('final_norm', '?'):.3e}")
|
|
132
|
+
logger.info(f" Iterations : {fs.solver_stats.get('iterations', '?')}")
|
|
133
|
+
logger.info(f" Backend : {fs.backend}")
|
|
134
|
+
logger.info(f" Snapshot ID : {snapshot_id}")
|
|
135
|
+
logger.info(f" Results zarr : {zarr_path}")
|
|
136
|
+
logger.info(f" Elapsed (s) : {elapsed:.2f}")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# Direct solve failed — try homotopy (only when topology is same and state exists)
|
|
140
|
+
if state is not None and not topology_changed and drifted and not skip_homotopy:
|
|
141
|
+
logger.warning("Direct solve failed. Attempting homotopy continuation...")
|
|
142
|
+
from ..eo.solver import EOSolver, solve_with_homotopy
|
|
143
|
+
from ..eo.flowsheet import EOFlowsheet as _EO
|
|
144
|
+
from ..providers.manager import teardown_providers
|
|
145
|
+
|
|
146
|
+
solver = EOSolver(backend=fs.backend, tol=tolerance, max_iter=max_iter)
|
|
147
|
+
tmp_fs = _EO(config, backend=backend)
|
|
148
|
+
manager = tmp_fs._build()
|
|
149
|
+
try:
|
|
150
|
+
x_hom, hom_converged, hom_stats = solve_with_homotopy(
|
|
151
|
+
tmp_fs, manager, solver, state, drifted
|
|
152
|
+
)
|
|
153
|
+
finally:
|
|
154
|
+
teardown_providers(tmp_fs._provider_map)
|
|
155
|
+
|
|
156
|
+
if hom_converged:
|
|
157
|
+
logger.info(
|
|
158
|
+
f"Homotopy converged: ||F||={hom_stats.get('final_norm', '?'):.3e}, "
|
|
159
|
+
f"iterations={hom_stats.get('iterations', '?')}"
|
|
160
|
+
)
|
|
161
|
+
save_snapshot(
|
|
162
|
+
sm, config, x_hom, fs.var_names,
|
|
163
|
+
metadata=current_metadata,
|
|
164
|
+
parent_snapshot_id=state.snapshot_id if state is not None and not topology_changed else None,
|
|
165
|
+
label="homotopy solution",
|
|
166
|
+
)
|
|
167
|
+
run_info = build_run_info(config, x0=fs.x0, var_names=fs.var_names)
|
|
168
|
+
save_results_zarr(
|
|
169
|
+
results,
|
|
170
|
+
os.path.join(outputs_dir, f"{base_name}_results.zarr"),
|
|
171
|
+
run_info=run_info,
|
|
172
|
+
)
|
|
173
|
+
logger.info("Homotopy apply succeeded. New snapshot saved.")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
# Both failed — auto-revert and write divergence report
|
|
177
|
+
logger.error("Homotopy also failed to converge.")
|
|
178
|
+
prev_id = state.snapshot_id if state is not None else "unknown"
|
|
179
|
+
sm.rollback(1)
|
|
180
|
+
logger.warning(f"Reverted .pfstate to snapshot before {prev_id}.")
|
|
181
|
+
breakdown = log_residual_breakdown(fs)
|
|
182
|
+
divergence = build_divergence_report(
|
|
183
|
+
drifted_params=drifted,
|
|
184
|
+
solver_stats=hom_stats,
|
|
185
|
+
x_last=x_hom,
|
|
186
|
+
var_names=fs.var_names,
|
|
187
|
+
breakdown=breakdown,
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
# Cold start also failed; no rollback (nothing to revert to)
|
|
191
|
+
if not drifted and state is None:
|
|
192
|
+
logger.error("Cold-start solve failed to converge (no prior snapshot).")
|
|
193
|
+
elif skip_homotopy:
|
|
194
|
+
logger.error("Cold-start solve failed to converge (--skip-homotopy).")
|
|
195
|
+
else:
|
|
196
|
+
logger.error("Cold-start solve failed to converge.")
|
|
197
|
+
breakdown = log_residual_breakdown(fs)
|
|
198
|
+
divergence = build_divergence_report(
|
|
199
|
+
drifted_params=[],
|
|
200
|
+
solver_stats=fs.solver_stats,
|
|
201
|
+
x_last=getattr(fs, "x_converged", []),
|
|
202
|
+
var_names=getattr(fs, "var_names", []),
|
|
203
|
+
breakdown=breakdown,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
write_divergence_report(outputs_dir, base_name, divergence)
|
|
207
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Shared CLI helpers — provider checks, state loading, metadata, divergence reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import hashlib
|
|
7
|
+
import importlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
from .. import __version__ as _pf_version
|
|
17
|
+
from ..utils.validate_flowsheet import validate_flowsheet
|
|
18
|
+
from ..state import StateManager
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ..state import SnapshotState
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Paths
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def output_root() -> str:
|
|
29
|
+
"""Root directory for run outputs (zarr, provider artifacts).
|
|
30
|
+
|
|
31
|
+
Defaults to ``outputs`` for local runs; the Docker image sets
|
|
32
|
+
``PROCESSFORGE_OUTPUT_DIR=/data`` so outputs land on the mounted volume.
|
|
33
|
+
"""
|
|
34
|
+
return os.environ.get("PROCESSFORGE_OUTPUT_DIR", "outputs")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def require_existing_file(path: str, label: str = "Flowsheet file") -> None:
|
|
38
|
+
"""Fail fast with a consistent message when an input path is missing."""
|
|
39
|
+
if not os.path.exists(path):
|
|
40
|
+
logger.error(f"{label} '{path}' not found.")
|
|
41
|
+
raise SystemExit(1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Flowsheet validation
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def validate_runtime_flowsheet(path: str) -> dict:
|
|
49
|
+
"""Validate flowsheet config and attach source path for runtime providers."""
|
|
50
|
+
try:
|
|
51
|
+
config = validate_flowsheet(path)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logger.error(f"Failed to validate flowsheet file '{path}': {e}")
|
|
54
|
+
logger.debug("Validation traceback:", exc_info=True)
|
|
55
|
+
raise SystemExit(1)
|
|
56
|
+
|
|
57
|
+
# Added after schema validation so additionalProperties:false doesn't reject it.
|
|
58
|
+
config["_config_path"] = path
|
|
59
|
+
return config
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_snapshot_config(state: "SnapshotState", base_name: str) -> None:
|
|
63
|
+
"""Warn if a loaded snapshot config has missing required fields."""
|
|
64
|
+
from ..types import FlowsheetConfig
|
|
65
|
+
|
|
66
|
+
snap_cfg = state.config
|
|
67
|
+
try:
|
|
68
|
+
FlowsheetConfig.from_dict(snap_cfg)
|
|
69
|
+
except (TypeError, KeyError) as exc:
|
|
70
|
+
logger.warning(
|
|
71
|
+
f"Snapshot '{state.snapshot_id}' for '{base_name}' has an "
|
|
72
|
+
f"incompatible config schema: {exc}. "
|
|
73
|
+
f"Delete 'outputs/{base_name}.pfstate' and re-run to create a "
|
|
74
|
+
f"fresh snapshot."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Metadata
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def build_run_metadata(config: dict, solver_tol: float, solver_max_iter: int, backend: str) -> dict:
|
|
83
|
+
"""Build run metadata for checkpoint storage."""
|
|
84
|
+
config_bytes = json.dumps(config, sort_keys=True).encode("utf-8")
|
|
85
|
+
flowsheet_hash = hashlib.sha256(config_bytes).hexdigest()[:16]
|
|
86
|
+
return {
|
|
87
|
+
"version": _pf_version,
|
|
88
|
+
"flowsheet_hash": flowsheet_hash,
|
|
89
|
+
"solver_settings": {
|
|
90
|
+
"tol": solver_tol,
|
|
91
|
+
"max_iter": solver_max_iter,
|
|
92
|
+
"backend": backend,
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extract_providers(flowsheet_path: str) -> dict:
|
|
98
|
+
"""Read flowsheet JSON and return the raw providers dict."""
|
|
99
|
+
try:
|
|
100
|
+
with open(flowsheet_path, "r", encoding="utf-8") as f:
|
|
101
|
+
config = json.load(f)
|
|
102
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
103
|
+
logger.error(f"Failed to read flowsheet '{flowsheet_path}': {e}")
|
|
104
|
+
raise SystemExit(1)
|
|
105
|
+
providers = config.get("providers", {})
|
|
106
|
+
if not providers:
|
|
107
|
+
logger.warning(f"No providers declared in '{flowsheet_path}'.")
|
|
108
|
+
return providers
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# State management
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def load_state_manager(outputs_dir: str, base_name: str) -> tuple[StateManager, "SnapshotState | None"]:
|
|
116
|
+
"""Create a StateManager and load the snapshot (if it exists)."""
|
|
117
|
+
state_path = os.path.join(outputs_dir, f"{base_name}.pfstate")
|
|
118
|
+
sm = StateManager(state_path)
|
|
119
|
+
state = sm.load_state()
|
|
120
|
+
return sm, state
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Provider checking
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def _resolve_provider_url(cfg: dict, ptype: str) -> str:
|
|
128
|
+
"""Build the provider URL, falling back to the default port."""
|
|
129
|
+
from ..providers.registry import get_provider_default_port
|
|
130
|
+
|
|
131
|
+
url = cfg.get("url")
|
|
132
|
+
if not url:
|
|
133
|
+
port = get_provider_default_port(ptype) or 9000
|
|
134
|
+
url = f"http://localhost:{port}"
|
|
135
|
+
return url
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def check_providers(
|
|
139
|
+
config: dict,
|
|
140
|
+
flowsheet_path: str,
|
|
141
|
+
*,
|
|
142
|
+
fail_fast: bool = True,
|
|
143
|
+
verbose: bool = False,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Verify all declared providers are reachable or importable.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
fail_fast:
|
|
150
|
+
If ``True`` (default), raise ``SystemExit`` on the first failure.
|
|
151
|
+
If ``False``, accumulate errors and raise once at the end.
|
|
152
|
+
verbose:
|
|
153
|
+
If ``True``, log per-provider success messages.
|
|
154
|
+
"""
|
|
155
|
+
from ..providers.registry import is_containerized, _PROVIDER_CATALOG
|
|
156
|
+
|
|
157
|
+
providers = config.get("providers", {})
|
|
158
|
+
errors: list[str] = []
|
|
159
|
+
|
|
160
|
+
for name, cfg in providers.items():
|
|
161
|
+
ptype = cfg.get("type", "")
|
|
162
|
+
if is_containerized(ptype):
|
|
163
|
+
url = _resolve_provider_url(cfg, ptype)
|
|
164
|
+
try:
|
|
165
|
+
urllib.request.urlopen(f"{url}/health", timeout=5)
|
|
166
|
+
if verbose:
|
|
167
|
+
logger.info(f"Provider '{name}' — reachable at {url}")
|
|
168
|
+
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
|
169
|
+
msg = (
|
|
170
|
+
f"Provider '{name}' unreachable at {url}. "
|
|
171
|
+
f"Run: pf init {flowsheet_path}"
|
|
172
|
+
)
|
|
173
|
+
if fail_fast:
|
|
174
|
+
logger.error(msg)
|
|
175
|
+
raise SystemExit(1)
|
|
176
|
+
logger.error(msg)
|
|
177
|
+
errors.append(msg)
|
|
178
|
+
else:
|
|
179
|
+
catalog = _PROVIDER_CATALOG.get(ptype, {})
|
|
180
|
+
module = catalog.get("module", "")
|
|
181
|
+
try:
|
|
182
|
+
importlib.util.find_spec(module)
|
|
183
|
+
if verbose:
|
|
184
|
+
logger.info(f"Provider '{name}' — importable (pip)")
|
|
185
|
+
except (ModuleNotFoundError, ValueError):
|
|
186
|
+
dep = catalog.get("optional_dep")
|
|
187
|
+
hint = f"pip install 'processforge[{dep}]'" if dep else "built-in"
|
|
188
|
+
msg = (
|
|
189
|
+
f"Provider '{name}' not installed. "
|
|
190
|
+
f"Run: pf init {flowsheet_path} (install with: {hint})"
|
|
191
|
+
)
|
|
192
|
+
if fail_fast:
|
|
193
|
+
logger.error(msg)
|
|
194
|
+
raise SystemExit(1)
|
|
195
|
+
logger.error(msg)
|
|
196
|
+
errors.append(msg)
|
|
197
|
+
|
|
198
|
+
if errors:
|
|
199
|
+
raise SystemExit(1)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Divergence reports
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def log_residual_breakdown(fs: Any) -> list[dict]:
|
|
207
|
+
"""Log the top residual violators from a failed solve and return the breakdown."""
|
|
208
|
+
breakdown = getattr(fs, "residual_breakdown", [])
|
|
209
|
+
if breakdown:
|
|
210
|
+
logger.error("Top residual violators:")
|
|
211
|
+
for entry in breakdown:
|
|
212
|
+
logger.error(
|
|
213
|
+
f" [{entry['index']:4d}] {entry['var_name']:<35s} |F| = {entry['residual']:.4e}"
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
logger.debug("No residual breakdown available from solver.")
|
|
217
|
+
return breakdown
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def build_divergence_report(
|
|
221
|
+
*,
|
|
222
|
+
drifted_params: list[str],
|
|
223
|
+
solver_stats: dict,
|
|
224
|
+
x_last: Any,
|
|
225
|
+
var_names: list[str],
|
|
226
|
+
breakdown: list[dict],
|
|
227
|
+
) -> dict:
|
|
228
|
+
"""Construct the divergence report dict for failed solves."""
|
|
229
|
+
x_last_list = x_last.tolist() if hasattr(x_last, "tolist") else (x_last if isinstance(x_last, list) else [])
|
|
230
|
+
var_names_list = var_names if var_names else []
|
|
231
|
+
|
|
232
|
+
if not x_last_list:
|
|
233
|
+
logger.debug("x_last unavailable — divergence report will have empty x_last field.")
|
|
234
|
+
if not var_names_list:
|
|
235
|
+
logger.debug("var_names unavailable — divergence report will have empty var_names field.")
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
239
|
+
"drifted_params": drifted_params,
|
|
240
|
+
"final_norm": solver_stats.get("final_norm"),
|
|
241
|
+
"solver_stats": solver_stats,
|
|
242
|
+
"x_last": x_last_list,
|
|
243
|
+
"var_names": var_names_list,
|
|
244
|
+
"top_violators": breakdown,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def write_divergence_report(outputs_dir: str, base_name: str, divergence: dict) -> None:
|
|
249
|
+
"""Write the divergence report JSON and log its path."""
|
|
250
|
+
div_path = os.path.join(outputs_dir, f"{base_name}_divergence.json")
|
|
251
|
+
with open(div_path, "w", encoding="utf-8") as f:
|
|
252
|
+
json.dump(divergence, f, indent=2)
|
|
253
|
+
logger.error(f"Divergence report written to {div_path}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# State saving (with error handling)
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def save_snapshot(
|
|
261
|
+
sm: StateManager,
|
|
262
|
+
config: dict,
|
|
263
|
+
x_converged: Any,
|
|
264
|
+
var_names: list[str],
|
|
265
|
+
*,
|
|
266
|
+
metadata: dict,
|
|
267
|
+
parent_snapshot_id: str | None,
|
|
268
|
+
label: str = "snapshot",
|
|
269
|
+
) -> str:
|
|
270
|
+
"""Save a state snapshot, logging a clear message on failure."""
|
|
271
|
+
try:
|
|
272
|
+
return sm.save_state(
|
|
273
|
+
config, x_converged, var_names,
|
|
274
|
+
metadata=metadata,
|
|
275
|
+
parent_snapshot_id=parent_snapshot_id,
|
|
276
|
+
)
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
logger.error(f"Failed to save {label}: {exc}")
|
|
279
|
+
logger.warning("Results were computed but state was not persisted.")
|
|
280
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""``pf diagram`` — generate a flowsheet diagram."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from ..utils.flowsheet_diagram import draw_flowsheet
|
|
13
|
+
from .common import require_existing_file
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def diagram(
|
|
17
|
+
flowsheet: str = typer.Argument(help="Path to the flowsheet JSON file"),
|
|
18
|
+
output_dir: str = typer.Option(
|
|
19
|
+
"diagrams",
|
|
20
|
+
"--output-dir",
|
|
21
|
+
"-o",
|
|
22
|
+
help="Output directory (default: diagrams)",
|
|
23
|
+
),
|
|
24
|
+
format: Literal["png", "svg", "pdf"] = typer.Option(
|
|
25
|
+
"png",
|
|
26
|
+
"--format",
|
|
27
|
+
"-f",
|
|
28
|
+
help="Output format (default: png)",
|
|
29
|
+
),
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Generate a flowsheet diagram from a JSON file."""
|
|
32
|
+
require_existing_file(flowsheet)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
with open(flowsheet, "r", encoding="utf-8") as f:
|
|
36
|
+
flowsheet_schema = json.load(f)
|
|
37
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
38
|
+
logger.error(f"Failed to read flowsheet '{flowsheet}': {e}")
|
|
39
|
+
raise SystemExit(1)
|
|
40
|
+
|
|
41
|
+
output_dir = output_dir or "diagrams"
|
|
42
|
+
fmt = format or "png"
|
|
43
|
+
|
|
44
|
+
base_name = os.path.splitext(os.path.basename(flowsheet))[0]
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
output_path = draw_flowsheet(
|
|
48
|
+
flowsheet_schema,
|
|
49
|
+
output_directory=output_dir,
|
|
50
|
+
output_filename=base_name,
|
|
51
|
+
file_format=fmt,
|
|
52
|
+
)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error(f"Failed to generate diagram: {type(e).__name__}: {e}")
|
|
55
|
+
raise SystemExit(1)
|
|
56
|
+
|
|
57
|
+
logger.info(f"Diagram saved to {output_path}")
|