processforge 0.3.2__tar.gz → 0.3.3__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.3.2/src/processforge.egg-info → processforge-0.3.3}/PKG-INFO +1 -1
- {processforge-0.3.2 → processforge-0.3.3}/pyproject.toml +1 -1
- processforge-0.3.3/src/processforge/cli/apply.py +238 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/cli/common.py +44 -0
- processforge-0.3.3/src/processforge/cli/run.py +137 -0
- processforge-0.3.3/src/processforge/compose.py +114 -0
- {processforge-0.3.2 → processforge-0.3.3/src/processforge.egg-info}/PKG-INFO +1 -1
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge.egg-info/SOURCES.txt +1 -1
- processforge-0.3.3/tests/test_cli.py +684 -0
- processforge-0.3.2/src/processforge/cli/apply.py +0 -208
- processforge-0.3.2/src/processforge/cli/run.py +0 -107
- processforge-0.3.2/src/processforge/compose.py +0 -47
- processforge-0.3.2/tests/test_cli.py +0 -380
- {processforge-0.3.2 → processforge-0.3.3}/LICENSE +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/MANIFEST.in +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/README.md +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/flowsheets/closed-loop-chain.json +0 -0
- /processforge-0.3.2/flowsheets/custom/crimson/MUWS.json → /processforge-0.3.3/flowsheets/custom/crimson/MUWS_HB.json +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/flowsheets/hydraulic-chain.json +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/flowsheets/openmc/msre_eigenvalue.json +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/setup.cfg +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/__main__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/_schema.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/analysis/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/analysis/dof.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/cli/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/cli/diagram.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/cli/display.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/cli/export_fmu.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/cli/export_modelica.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/cli/init.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/cli/plan.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/cli/validate.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/backends/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/backends/base.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/backends/casadi_backend.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/backends/pyomo_backend.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/backends/scipy_backend.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/flowsheet.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/jacobian.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/mixin.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/solver.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/stream_var.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/units/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/units/flash_eo.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/units/heater_eo.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/units/pipes_eo.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/units/pump_eo.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/units/strainer_eo.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/eo/units/valve_eo.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/flowsheet.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/fmu/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/fmu/_fmi_vars.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/fmu/builder.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/fmu/slave_template.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/lock.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/modelica/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/modelica/mo_writer.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/modelica/omc_runner.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/modelica/transpiler.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/modelica/unit_equations.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/pcl/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/pcl/compiler.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/pcl/namespace.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/provenance.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/base.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/base_jacobian_mixin.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/cantera_jacobian.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/cantera_provider.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/coolprop_provider.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/festim_provider.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/jacobian_contributor.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/manager.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/modelica_jacobian.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/modelica_provider.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/openmc_provider.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/reference_state_registry.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/providers/registry.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/result.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/result_schema.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/schemas/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/schemas/flowsheet_schema.json +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/schemas/openmc/openmc_model.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/simulate.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/solver.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/state.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/thermo.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/types.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/base.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/cstr.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/flash.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/heater.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/pfr.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/pipes.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/pump.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/registry.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/solver_unit.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/strainer.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/tank.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/units/valve.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/utils/__init__.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/utils/flowsheet_diagram.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/utils/mermaid_diagram.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/utils/topology.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/utils/unit_consistency.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/utils/validate_flowsheet.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge/utils/validation.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge.egg-info/dependency_links.txt +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge.egg-info/entry_points.txt +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge.egg-info/requires.txt +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/src/processforge.egg-info/top_level.txt +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/tests/test_festim_provider.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/tests/test_init.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/tests/test_providers.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/tests/test_state.py +0 -0
- {processforge-0.3.2 → processforge-0.3.3}/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.3"
|
|
8
8
|
description = "A Python-based process simulation framework for chemical engineering applications."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "BSD-3-Clause"
|
|
@@ -0,0 +1,238 @@
|
|
|
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 ..compose import start_provider_containers, stop_provider_containers
|
|
17
|
+
from .common import (
|
|
18
|
+
build_divergence_report,
|
|
19
|
+
build_run_metadata,
|
|
20
|
+
check_providers,
|
|
21
|
+
flowsheet_basename,
|
|
22
|
+
has_containerized_providers,
|
|
23
|
+
load_state_manager,
|
|
24
|
+
log_residual_breakdown,
|
|
25
|
+
output_root,
|
|
26
|
+
require_existing_file,
|
|
27
|
+
save_snapshot,
|
|
28
|
+
validate_runtime_flowsheet,
|
|
29
|
+
wait_for_provider_health,
|
|
30
|
+
write_divergence_report,
|
|
31
|
+
)
|
|
32
|
+
from .display import print_structural_diff
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def apply(
|
|
36
|
+
flowsheet: str = typer.Argument(help="Path to the flowsheet JSON file"),
|
|
37
|
+
backend: Literal["scipy", "pyomo", "casadi"] | None = typer.Option(
|
|
38
|
+
None,
|
|
39
|
+
"--backend",
|
|
40
|
+
help="Override the flowsheet's simulation.backend",
|
|
41
|
+
),
|
|
42
|
+
tolerance: float = typer.Option(
|
|
43
|
+
1e-6,
|
|
44
|
+
"--tolerance",
|
|
45
|
+
help="Newton solver convergence tolerance (default: 1e-6)",
|
|
46
|
+
),
|
|
47
|
+
max_iter: int = typer.Option(
|
|
48
|
+
50,
|
|
49
|
+
"--max-iter",
|
|
50
|
+
help="Max Newton iterations (default: 50)",
|
|
51
|
+
),
|
|
52
|
+
skip_homotopy: bool = typer.Option(
|
|
53
|
+
False,
|
|
54
|
+
"--skip-homotopy",
|
|
55
|
+
help="Disable homotopy fallback; cold-start only",
|
|
56
|
+
),
|
|
57
|
+
keep_containers: bool = typer.Option(
|
|
58
|
+
False,
|
|
59
|
+
"--keep-containers",
|
|
60
|
+
help="Do not stop provider containers after the run (leave them running)",
|
|
61
|
+
),
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Apply flowsheet: drift detection, warm-start, homotopy fallback, convergence guardrails."""
|
|
64
|
+
require_existing_file(flowsheet)
|
|
65
|
+
config = validate_runtime_flowsheet(flowsheet)
|
|
66
|
+
|
|
67
|
+
if not isinstance(keep_containers, bool):
|
|
68
|
+
keep_containers = False
|
|
69
|
+
|
|
70
|
+
# Start containerized providers only for flowsheets that declare them.
|
|
71
|
+
compose_pf_dir = ".processforge"
|
|
72
|
+
started = False
|
|
73
|
+
if has_containerized_providers(config):
|
|
74
|
+
compose_path = os.path.join(compose_pf_dir, "docker-compose.yml")
|
|
75
|
+
if not os.path.exists(compose_path):
|
|
76
|
+
logger.warning(
|
|
77
|
+
"Containerized providers declared but no docker-compose.yml found. "
|
|
78
|
+
"Run: pf init <flowsheet>"
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
started = start_provider_containers(compose_pf_dir)
|
|
82
|
+
if started:
|
|
83
|
+
wait_for_provider_health(config)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
# Check provider availability
|
|
87
|
+
check_providers(config, flowsheet)
|
|
88
|
+
|
|
89
|
+
base_name = flowsheet_basename(flowsheet)
|
|
90
|
+
|
|
91
|
+
sim_cfg = config.get("simulation", {})
|
|
92
|
+
mode = sim_cfg.get("mode", "steady")
|
|
93
|
+
if mode != "steady":
|
|
94
|
+
logger.error("pf apply is only supported for steady-state EO flowsheets.")
|
|
95
|
+
raise SystemExit(1)
|
|
96
|
+
|
|
97
|
+
outputs_dir = output_root()
|
|
98
|
+
os.makedirs(outputs_dir, exist_ok=True)
|
|
99
|
+
sm, state = load_state_manager(outputs_dir, base_name)
|
|
100
|
+
|
|
101
|
+
# Structural diff: detect topology changes
|
|
102
|
+
topology_changed = False
|
|
103
|
+
if state is not None:
|
|
104
|
+
diff = sm.detect_structural_diff(config, state)
|
|
105
|
+
print_structural_diff(diff)
|
|
106
|
+
topology_changed = bool(diff.get("added") or diff.get("removed"))
|
|
107
|
+
if topology_changed:
|
|
108
|
+
logger.warning(
|
|
109
|
+
"Topology changed (units added/removed). "
|
|
110
|
+
"Homotopy requires identical topology — falling back to cold start."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Parameter drift (only meaningful when topology is unchanged)
|
|
114
|
+
drifted: list[str] = []
|
|
115
|
+
current_metadata = build_run_metadata(config, tolerance, max_iter, backend or "scipy")
|
|
116
|
+
if state is not None and not topology_changed:
|
|
117
|
+
mismatches = sm.validate_metadata(current_metadata, state)
|
|
118
|
+
if mismatches:
|
|
119
|
+
logger.warning(f"Metadata mismatch: {mismatches}")
|
|
120
|
+
drifted = sm.detect_drift(config, state)
|
|
121
|
+
if not drifted:
|
|
122
|
+
logger.info("No drift detected. System is already at the desired state.")
|
|
123
|
+
return
|
|
124
|
+
stream_drifts = [d for d in drifted if d.startswith("streams.")]
|
|
125
|
+
unit_drifts = [d for d in drifted if d.startswith("units.")]
|
|
126
|
+
logger.warning("Drift detected:")
|
|
127
|
+
if stream_drifts:
|
|
128
|
+
logger.warning(f" Stream drift : {stream_drifts}")
|
|
129
|
+
if unit_drifts:
|
|
130
|
+
logger.warning(f" Unit drift : {unit_drifts}")
|
|
131
|
+
|
|
132
|
+
# Build flowsheet; attach saved state for warm-start unless topology changed
|
|
133
|
+
fs = EOFlowsheet(config, backend=backend)
|
|
134
|
+
fs.saved_state = state if not topology_changed else None
|
|
135
|
+
fs.solver_tol = tolerance
|
|
136
|
+
fs.solver_max_iter = max_iter
|
|
137
|
+
|
|
138
|
+
logger.info("=== Running Apply (Steady-State EO) ===")
|
|
139
|
+
t0 = time.perf_counter()
|
|
140
|
+
results = fs.run()
|
|
141
|
+
elapsed = time.perf_counter() - t0
|
|
142
|
+
|
|
143
|
+
logger.info(
|
|
144
|
+
f"Direct solve completed in {elapsed:.2f}s (converged={fs.converged})."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if fs.converged:
|
|
148
|
+
snapshot_id = save_snapshot(
|
|
149
|
+
sm, config, fs.x_converged, fs.var_names,
|
|
150
|
+
metadata=current_metadata,
|
|
151
|
+
parent_snapshot_id=state.snapshot_id if state is not None and not topology_changed else None,
|
|
152
|
+
label="converged state",
|
|
153
|
+
)
|
|
154
|
+
run_info = build_run_info(config, x0=fs.x0, var_names=fs.var_names)
|
|
155
|
+
zarr_path = os.path.join(outputs_dir, f"{base_name}_results.zarr")
|
|
156
|
+
save_results_zarr(results, zarr_path, run_info=run_info)
|
|
157
|
+
logger.info("=== Apply Summary ===")
|
|
158
|
+
logger.info(" Status : CONVERGED")
|
|
159
|
+
logger.info(f" Final ||F|| : {fs.solver_stats.get('final_norm', '?'):.3e}")
|
|
160
|
+
logger.info(f" Iterations : {fs.solver_stats.get('iterations', '?')}")
|
|
161
|
+
logger.info(f" Backend : {fs.backend}")
|
|
162
|
+
logger.info(f" Snapshot ID : {snapshot_id}")
|
|
163
|
+
logger.info(f" Results zarr : {zarr_path}")
|
|
164
|
+
logger.info(f" Elapsed (s) : {elapsed:.2f}")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# Direct solve failed — try homotopy (only when topology is same and state exists)
|
|
168
|
+
if state is not None and not topology_changed and drifted and not skip_homotopy:
|
|
169
|
+
logger.warning("Direct solve failed. Attempting homotopy continuation...")
|
|
170
|
+
from ..eo.solver import EOSolver, solve_with_homotopy
|
|
171
|
+
from ..eo.flowsheet import EOFlowsheet as _EO
|
|
172
|
+
from ..providers.manager import teardown_providers
|
|
173
|
+
|
|
174
|
+
solver = EOSolver(backend=fs.backend, tol=tolerance, max_iter=max_iter)
|
|
175
|
+
tmp_fs = _EO(config, backend=backend)
|
|
176
|
+
manager = tmp_fs._build()
|
|
177
|
+
try:
|
|
178
|
+
x_hom, hom_converged, hom_stats = solve_with_homotopy(
|
|
179
|
+
tmp_fs, manager, solver, state, drifted
|
|
180
|
+
)
|
|
181
|
+
finally:
|
|
182
|
+
teardown_providers(tmp_fs._provider_map)
|
|
183
|
+
|
|
184
|
+
if hom_converged:
|
|
185
|
+
logger.info(
|
|
186
|
+
f"Homotopy converged: ||F||={hom_stats.get('final_norm', '?'):.3e}, "
|
|
187
|
+
f"iterations={hom_stats.get('iterations', '?')}"
|
|
188
|
+
)
|
|
189
|
+
save_snapshot(
|
|
190
|
+
sm, config, x_hom, fs.var_names,
|
|
191
|
+
metadata=current_metadata,
|
|
192
|
+
parent_snapshot_id=state.snapshot_id if state is not None and not topology_changed else None,
|
|
193
|
+
label="homotopy solution",
|
|
194
|
+
)
|
|
195
|
+
run_info = build_run_info(config, x0=fs.x0, var_names=fs.var_names)
|
|
196
|
+
save_results_zarr(
|
|
197
|
+
results,
|
|
198
|
+
os.path.join(outputs_dir, f"{base_name}_results.zarr"),
|
|
199
|
+
run_info=run_info,
|
|
200
|
+
)
|
|
201
|
+
logger.info("Homotopy apply succeeded. New snapshot saved.")
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
# Both failed — auto-revert and write divergence report
|
|
205
|
+
logger.error("Homotopy also failed to converge.")
|
|
206
|
+
prev_id = state.snapshot_id if state is not None else "unknown"
|
|
207
|
+
sm.rollback(1)
|
|
208
|
+
logger.warning(f"Reverted .pfstate to snapshot before {prev_id}.")
|
|
209
|
+
breakdown = log_residual_breakdown(fs)
|
|
210
|
+
divergence = build_divergence_report(
|
|
211
|
+
drifted_params=drifted,
|
|
212
|
+
solver_stats=hom_stats,
|
|
213
|
+
x_last=x_hom,
|
|
214
|
+
var_names=fs.var_names,
|
|
215
|
+
breakdown=breakdown,
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
# Cold start also failed; no rollback (nothing to revert to)
|
|
219
|
+
if not drifted and state is None:
|
|
220
|
+
logger.error("Cold-start solve failed to converge (no prior snapshot).")
|
|
221
|
+
elif skip_homotopy:
|
|
222
|
+
logger.error("Cold-start solve failed to converge (--skip-homotopy).")
|
|
223
|
+
else:
|
|
224
|
+
logger.error("Cold-start solve failed to converge.")
|
|
225
|
+
breakdown = log_residual_breakdown(fs)
|
|
226
|
+
divergence = build_divergence_report(
|
|
227
|
+
drifted_params=[],
|
|
228
|
+
solver_stats=fs.solver_stats,
|
|
229
|
+
x_last=getattr(fs, "x_converged", []),
|
|
230
|
+
var_names=getattr(fs, "var_names", []),
|
|
231
|
+
breakdown=breakdown,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
write_divergence_report(outputs_dir, base_name, divergence)
|
|
235
|
+
raise SystemExit(1)
|
|
236
|
+
finally:
|
|
237
|
+
if started and not keep_containers:
|
|
238
|
+
stop_provider_containers(compose_pf_dir)
|
|
@@ -203,6 +203,50 @@ def check_providers(
|
|
|
203
203
|
raise SystemExit(1)
|
|
204
204
|
|
|
205
205
|
|
|
206
|
+
def has_containerized_providers(config: dict) -> bool:
|
|
207
|
+
"""Return True if any declared provider runs in a Docker container."""
|
|
208
|
+
from ..providers.registry import is_containerized
|
|
209
|
+
|
|
210
|
+
return any(
|
|
211
|
+
is_containerized(cfg.get("type", ""))
|
|
212
|
+
for cfg in config.get("providers", {}).values()
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def wait_for_provider_health(config: dict, timeout: int = 30) -> None:
|
|
217
|
+
"""Poll ``/health`` for each localhost containerized provider until ready.
|
|
218
|
+
|
|
219
|
+
Only providers whose resolved URL host is ``localhost``/``127.0.0.1`` are
|
|
220
|
+
waited on — remote providers are assumed to be managed elsewhere.
|
|
221
|
+
"""
|
|
222
|
+
import time
|
|
223
|
+
import urllib.parse
|
|
224
|
+
|
|
225
|
+
from ..providers.registry import is_containerized
|
|
226
|
+
|
|
227
|
+
providers = config.get("providers", {})
|
|
228
|
+
deadline = time.time() + timeout
|
|
229
|
+
for name, cfg in providers.items():
|
|
230
|
+
if not is_containerized(cfg.get("type", "")):
|
|
231
|
+
continue
|
|
232
|
+
url = _resolve_provider_url(cfg, cfg.get("type", ""))
|
|
233
|
+
host = urllib.parse.urlparse(url).hostname
|
|
234
|
+
if host not in ("localhost", "127.0.0.1"):
|
|
235
|
+
continue
|
|
236
|
+
health = f"{url}/health"
|
|
237
|
+
while time.time() < deadline:
|
|
238
|
+
try:
|
|
239
|
+
urllib.request.urlopen(health, timeout=2)
|
|
240
|
+
logger.info(f"Provider '{name}' ready at {url}")
|
|
241
|
+
break
|
|
242
|
+
except (urllib.error.URLError, OSError):
|
|
243
|
+
time.sleep(1)
|
|
244
|
+
else:
|
|
245
|
+
logger.warning(
|
|
246
|
+
f"Provider '{name}' not ready at {url} after {timeout}s."
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
206
250
|
# ---------------------------------------------------------------------------
|
|
207
251
|
# Divergence reports
|
|
208
252
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""``pf run`` — run a process simulation from a flowsheet JSON file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from ..flowsheet import Flowsheet
|
|
11
|
+
from ..eo import EOFlowsheet
|
|
12
|
+
from ..provenance import build_dynamic_x0, build_run_info
|
|
13
|
+
from ..result import plot_results, plot_timeseries, save_results_zarr
|
|
14
|
+
from ..state import StateManager
|
|
15
|
+
from ..compose import start_provider_containers, stop_provider_containers
|
|
16
|
+
from .common import (
|
|
17
|
+
check_providers,
|
|
18
|
+
flowsheet_basename,
|
|
19
|
+
has_containerized_providers,
|
|
20
|
+
output_root,
|
|
21
|
+
require_existing_file,
|
|
22
|
+
validate_runtime_flowsheet,
|
|
23
|
+
wait_for_provider_health,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run(
|
|
28
|
+
flowsheet: str = typer.Argument(help="Path to the flowsheet JSON file"),
|
|
29
|
+
export_images: bool = typer.Option(
|
|
30
|
+
False,
|
|
31
|
+
"--export-images",
|
|
32
|
+
help="Generate PNG plots for simulation outputs",
|
|
33
|
+
),
|
|
34
|
+
keep_containers: bool = typer.Option(
|
|
35
|
+
False,
|
|
36
|
+
"--keep-containers",
|
|
37
|
+
help="Do not stop provider containers after the run (leave them running)",
|
|
38
|
+
),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Run a process simulation from a flowsheet JSON file."""
|
|
41
|
+
require_existing_file(flowsheet)
|
|
42
|
+
config = validate_runtime_flowsheet(flowsheet)
|
|
43
|
+
|
|
44
|
+
if not isinstance(keep_containers, bool):
|
|
45
|
+
keep_containers = False
|
|
46
|
+
|
|
47
|
+
# Start containerized providers only for flowsheets that declare them.
|
|
48
|
+
compose_pf_dir = ".processforge"
|
|
49
|
+
started = False
|
|
50
|
+
if has_containerized_providers(config):
|
|
51
|
+
compose_path = os.path.join(compose_pf_dir, "docker-compose.yml")
|
|
52
|
+
if not os.path.exists(compose_path):
|
|
53
|
+
logger.warning(
|
|
54
|
+
"Containerized providers declared but no docker-compose.yml found. "
|
|
55
|
+
"Run: pf init <flowsheet>"
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
started = start_provider_containers(compose_pf_dir)
|
|
59
|
+
if started:
|
|
60
|
+
wait_for_provider_health(config)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
# Check provider availability
|
|
64
|
+
check_providers(config, flowsheet)
|
|
65
|
+
|
|
66
|
+
base_name = flowsheet_basename(flowsheet)
|
|
67
|
+
outputs_dir = output_root()
|
|
68
|
+
|
|
69
|
+
sim_cfg = config.get("simulation", {})
|
|
70
|
+
mode = sim_cfg.get("mode", "steady")
|
|
71
|
+
is_dynamic = mode == "dynamic"
|
|
72
|
+
|
|
73
|
+
if is_dynamic:
|
|
74
|
+
# Load .pfstate as t=0 if available
|
|
75
|
+
state_path = os.path.join(outputs_dir, f"{base_name}.pfstate")
|
|
76
|
+
sm = StateManager(state_path)
|
|
77
|
+
state = sm.load_state()
|
|
78
|
+
if state is not None:
|
|
79
|
+
stream_inits = sm.state_to_stream_dicts(state)
|
|
80
|
+
streams_cfg = config.get("streams", {})
|
|
81
|
+
for s_name, s_vals in stream_inits.items():
|
|
82
|
+
if s_name in streams_cfg:
|
|
83
|
+
streams_cfg[s_name].update(s_vals)
|
|
84
|
+
else:
|
|
85
|
+
logger.debug(
|
|
86
|
+
f"Stream '{s_name}' from snapshot not in current flowsheet — skipped."
|
|
87
|
+
)
|
|
88
|
+
logger.info("Using .pfstate converged state as dynamic t=0.")
|
|
89
|
+
else:
|
|
90
|
+
logger.debug("No .pfstate found — starting dynamic run from flowsheet defaults.")
|
|
91
|
+
|
|
92
|
+
fs = Flowsheet(config)
|
|
93
|
+
logger.info("=== Dynamic Results ===")
|
|
94
|
+
results = fs.run()
|
|
95
|
+
|
|
96
|
+
if hasattr(fs, "converged"):
|
|
97
|
+
if fs.converged:
|
|
98
|
+
logger.info("Dynamic simulation converged.")
|
|
99
|
+
else:
|
|
100
|
+
logger.warning("Dynamic simulation did NOT converge. Results may be unreliable.")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
x0, var_names = build_dynamic_x0(config)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.error(f"Failed to build initial state vector: {type(e).__name__}: {e}")
|
|
106
|
+
raise SystemExit(1)
|
|
107
|
+
|
|
108
|
+
run_info = build_run_info(config, x0=x0, var_names=var_names)
|
|
109
|
+
else:
|
|
110
|
+
# Pass None so EOFlowsheet resolves backend from config (with scipy default).
|
|
111
|
+
fs = EOFlowsheet(config, backend=None)
|
|
112
|
+
logger.info("=== Steady-State EO Results ===")
|
|
113
|
+
results = fs.run()
|
|
114
|
+
|
|
115
|
+
if hasattr(fs, "converged"):
|
|
116
|
+
if fs.converged:
|
|
117
|
+
logger.info("Steady-state simulation converged.")
|
|
118
|
+
else:
|
|
119
|
+
logger.warning("Steady-state simulation did NOT converge. Results may be unreliable.")
|
|
120
|
+
|
|
121
|
+
run_info = build_run_info(config, x0=fs.x0, var_names=fs.var_names)
|
|
122
|
+
|
|
123
|
+
os.makedirs(outputs_dir, exist_ok=True)
|
|
124
|
+
zarr_path = os.path.join(outputs_dir, f"{base_name}_results.zarr")
|
|
125
|
+
save_results_zarr(results, zarr_path, run_info=run_info)
|
|
126
|
+
logger.info(f"Results saved to {zarr_path}")
|
|
127
|
+
|
|
128
|
+
if export_images:
|
|
129
|
+
try:
|
|
130
|
+
plot_results(results, fname=f"{base_name}_results.png")
|
|
131
|
+
plot_timeseries(results, fname=f"{base_name}_timeseries.png")
|
|
132
|
+
logger.info(f"Plots saved: {base_name}_results.png, {base_name}_timeseries.png")
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.warning(f"Failed to generate plots: {type(e).__name__}: {e}")
|
|
135
|
+
finally:
|
|
136
|
+
if started and not keep_containers:
|
|
137
|
+
stop_provider_containers(compose_pf_dir)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Generate and manage docker-compose.yml for containerized providers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
COMPOSE_FILENAME = "docker-compose.yml"
|
|
10
|
+
COMPOSE_HEADER = (
|
|
11
|
+
"# Auto-generated by pf init — do not edit manually.\n"
|
|
12
|
+
"# Re-generate: pf init <flowsheet.json>\n"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _compose_path(pf_dir: str) -> str:
|
|
17
|
+
return os.path.join(pf_dir, COMPOSE_FILENAME)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_compose(
|
|
21
|
+
pf_dir: str,
|
|
22
|
+
containerized_providers: dict[str, dict],
|
|
23
|
+
outputs_dir: str = "outputs",
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Generate docker-compose.yml for providers that need Docker.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
pf_dir: Path to .processforge/ directory.
|
|
29
|
+
containerized_providers: Dict of provider_name -> {"url": ..., "docker_image": ..., "port": ...}.
|
|
30
|
+
outputs_dir: Host directory to mount as /data in containers.
|
|
31
|
+
"""
|
|
32
|
+
if not containerized_providers:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
lines = [COMPOSE_HEADER, "services:"]
|
|
36
|
+
for name, info in containerized_providers.items():
|
|
37
|
+
image = info["docker_image"]
|
|
38
|
+
port = info.get("port") or 9000
|
|
39
|
+
lines.append(f" {name}:")
|
|
40
|
+
lines.append(f" image: {image}")
|
|
41
|
+
# Bind directly to the host network so the provider is reachable at
|
|
42
|
+
# http://localhost:<port>. Docker bridge port-publishing is unreliable
|
|
43
|
+
# on some hosts (the published port is not reachable from the host),
|
|
44
|
+
# and host networking avoids that entirely.
|
|
45
|
+
lines.append(f" network_mode: host")
|
|
46
|
+
lines.append(f" volumes:")
|
|
47
|
+
lines.append(f' - "${{PROCESSFORGE_OUTPUT_DIR:-{outputs_dir}}}:/data"')
|
|
48
|
+
lines.append("")
|
|
49
|
+
|
|
50
|
+
os.makedirs(pf_dir, exist_ok=True)
|
|
51
|
+
path = _compose_path(pf_dir)
|
|
52
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
53
|
+
f.write("\n".join(lines))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def start_provider_containers(pf_dir: str = ".processforge") -> bool:
|
|
57
|
+
"""Start provider containers via ``docker compose up -d``.
|
|
58
|
+
|
|
59
|
+
Returns ``True`` if containers were started (or were already running),
|
|
60
|
+
``False`` if there is no compose file to act on or Docker is unavailable.
|
|
61
|
+
"""
|
|
62
|
+
compose_path = _compose_path(pf_dir)
|
|
63
|
+
if not os.path.exists(compose_path):
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
result = subprocess.run(
|
|
67
|
+
["docker", "compose", "-f", compose_path, "up", "-d"],
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
timeout=180,
|
|
71
|
+
)
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
logger.warning(
|
|
74
|
+
"Docker not found — cannot start provider containers. "
|
|
75
|
+
"Install Docker or start providers manually."
|
|
76
|
+
)
|
|
77
|
+
return False
|
|
78
|
+
except subprocess.TimeoutExpired:
|
|
79
|
+
logger.warning("docker compose up timed out after 180s.")
|
|
80
|
+
return False
|
|
81
|
+
if result.returncode != 0:
|
|
82
|
+
logger.warning(f"docker compose up failed: {result.stderr}")
|
|
83
|
+
return False
|
|
84
|
+
logger.info("Started provider container(s).")
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def stop_provider_containers(pf_dir: str = ".processforge") -> None:
|
|
89
|
+
"""Stop and remove provider containers via ``docker compose down``.
|
|
90
|
+
|
|
91
|
+
No-op when no compose file exists.
|
|
92
|
+
"""
|
|
93
|
+
compose_path = _compose_path(pf_dir)
|
|
94
|
+
if not os.path.exists(compose_path):
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
result = subprocess.run(
|
|
98
|
+
["docker", "compose", "-f", compose_path, "down"],
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
timeout=120,
|
|
102
|
+
)
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
logger.warning(
|
|
105
|
+
"Docker not found — could not stop provider containers."
|
|
106
|
+
)
|
|
107
|
+
return
|
|
108
|
+
except subprocess.TimeoutExpired:
|
|
109
|
+
logger.warning("docker compose down timed out after 120s.")
|
|
110
|
+
return
|
|
111
|
+
if result.returncode != 0:
|
|
112
|
+
logger.warning(f"docker compose down failed: {result.stderr}")
|
|
113
|
+
return
|
|
114
|
+
logger.info("Stopped provider container(s).")
|
|
@@ -5,7 +5,7 @@ pyproject.toml
|
|
|
5
5
|
flowsheets/closed-loop-chain.json
|
|
6
6
|
flowsheets/hydraulic-chain.json
|
|
7
7
|
flowsheets/custom/simple_hyrdaulic_scheme.json
|
|
8
|
-
flowsheets/custom/crimson/
|
|
8
|
+
flowsheets/custom/crimson/MUWS_HB.json
|
|
9
9
|
flowsheets/festim/hydrogen_transport_1d.json
|
|
10
10
|
flowsheets/openmc/msre_eigenvalue.json
|
|
11
11
|
src/processforge/__init__.py
|