processforge 0.2.38__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. {processforge-0.2.38 → processforge-0.3.1}/PKG-INFO +9 -4
  2. {processforge-0.2.38 → processforge-0.3.1}/README.md +6 -3
  3. {processforge-0.2.38 → processforge-0.3.1}/pyproject.toml +3 -1
  4. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/__init__.py +1 -1
  5. processforge-0.3.1/src/processforge/cli/__init__.py +44 -0
  6. processforge-0.3.1/src/processforge/cli/apply.py +207 -0
  7. processforge-0.3.1/src/processforge/cli/common.py +280 -0
  8. processforge-0.3.1/src/processforge/cli/diagram.py +57 -0
  9. processforge-0.3.1/src/processforge/cli/display.py +69 -0
  10. processforge-0.3.1/src/processforge/cli/export_fmu.py +41 -0
  11. processforge-0.3.1/src/processforge/cli/export_modelica.py +50 -0
  12. processforge-0.3.1/src/processforge/cli/init.py +171 -0
  13. processforge-0.3.1/src/processforge/cli/plan.py +132 -0
  14. processforge-0.3.1/src/processforge/cli/run.py +106 -0
  15. processforge-0.3.1/src/processforge/cli/validate.py +20 -0
  16. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/result.py +163 -57
  17. processforge-0.3.1/src/processforge/result_schema.py +26 -0
  18. processforge-0.3.1/src/processforge/simulate.py +59 -0
  19. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/types.py +51 -115
  20. {processforge-0.2.38 → processforge-0.3.1}/src/processforge.egg-info/PKG-INFO +9 -4
  21. {processforge-0.2.38 → processforge-0.3.1}/src/processforge.egg-info/SOURCES.txt +15 -1
  22. {processforge-0.2.38 → processforge-0.3.1}/src/processforge.egg-info/requires.txt +2 -0
  23. processforge-0.3.1/tests/test_cli.py +380 -0
  24. {processforge-0.2.38 → processforge-0.3.1}/tests/test_init.py +9 -9
  25. processforge-0.3.1/tests/test_types.py +440 -0
  26. processforge-0.2.38/src/processforge/simulate.py +0 -930
  27. {processforge-0.2.38 → processforge-0.3.1}/LICENSE +0 -0
  28. {processforge-0.2.38 → processforge-0.3.1}/MANIFEST.in +0 -0
  29. {processforge-0.2.38 → processforge-0.3.1}/flowsheets/closed-loop-chain.json +0 -0
  30. {processforge-0.2.38 → processforge-0.3.1}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
  31. {processforge-0.2.38 → processforge-0.3.1}/flowsheets/hydraulic-chain.json +0 -0
  32. {processforge-0.2.38 → processforge-0.3.1}/flowsheets/openmc/msre_eigenvalue.json +0 -0
  33. {processforge-0.2.38 → processforge-0.3.1}/setup.cfg +0 -0
  34. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/__main__.py +0 -0
  35. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/_schema.py +0 -0
  36. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/analysis/__init__.py +0 -0
  37. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/analysis/dof.py +0 -0
  38. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/compose.py +0 -0
  39. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/__init__.py +0 -0
  40. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/backends/__init__.py +0 -0
  41. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/backends/base.py +0 -0
  42. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/backends/casadi_backend.py +0 -0
  43. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  44. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/backends/scipy_backend.py +0 -0
  45. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/flowsheet.py +0 -0
  46. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/jacobian.py +0 -0
  47. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/mixin.py +0 -0
  48. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/solver.py +0 -0
  49. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/stream_var.py +0 -0
  50. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/units/__init__.py +0 -0
  51. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/units/flash_eo.py +0 -0
  52. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/units/heater_eo.py +0 -0
  53. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/units/pipes_eo.py +0 -0
  54. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/units/pump_eo.py +0 -0
  55. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/units/strainer_eo.py +0 -0
  56. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/eo/units/valve_eo.py +0 -0
  57. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/flowsheet.py +0 -0
  58. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/fmu/__init__.py +0 -0
  59. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/fmu/_fmi_vars.py +0 -0
  60. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/fmu/builder.py +0 -0
  61. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/fmu/slave_template.py +0 -0
  62. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/lock.py +0 -0
  63. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/modelica/__init__.py +0 -0
  64. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/modelica/mo_writer.py +0 -0
  65. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/modelica/omc_runner.py +0 -0
  66. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/modelica/transpiler.py +0 -0
  67. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/modelica/unit_equations.py +0 -0
  68. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/pcl/__init__.py +0 -0
  69. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/pcl/compiler.py +0 -0
  70. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/pcl/namespace.py +0 -0
  71. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/provenance.py +0 -0
  72. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/__init__.py +0 -0
  73. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/base.py +0 -0
  74. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/base_jacobian_mixin.py +0 -0
  75. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/cantera_jacobian.py +0 -0
  76. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/cantera_provider.py +0 -0
  77. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/coolprop_provider.py +0 -0
  78. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/festim_provider.py +0 -0
  79. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/jacobian_contributor.py +0 -0
  80. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/manager.py +0 -0
  81. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/modelica_jacobian.py +0 -0
  82. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/modelica_provider.py +0 -0
  83. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/openmc_provider.py +0 -0
  84. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/reference_state_registry.py +0 -0
  85. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/providers/registry.py +0 -0
  86. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/schemas/__init__.py +0 -0
  87. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/schemas/flowsheet_schema.json +0 -0
  88. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/schemas/openmc/openmc_model.py +0 -0
  89. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/solver.py +0 -0
  90. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/state.py +0 -0
  91. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/thermo.py +0 -0
  92. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/__init__.py +0 -0
  93. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/cstr.py +0 -0
  94. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/flash.py +0 -0
  95. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/heater.py +0 -0
  96. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/pfr.py +0 -0
  97. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/pipes.py +0 -0
  98. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/provider_mixin.py +0 -0
  99. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/pump.py +0 -0
  100. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/registry.py +0 -0
  101. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/solver.py +0 -0
  102. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/solver_unit.py +0 -0
  103. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/strainer.py +0 -0
  104. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/tank.py +0 -0
  105. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/units/valve.py +0 -0
  106. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/utils/__init__.py +0 -0
  107. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/utils/flowsheet_diagram.py +0 -0
  108. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/utils/mermaid_diagram.py +0 -0
  109. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/utils/topology.py +0 -0
  110. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/utils/unit_consistency.py +0 -0
  111. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/utils/validate_flowsheet.py +0 -0
  112. {processforge-0.2.38 → processforge-0.3.1}/src/processforge/utils/validation.py +0 -0
  113. {processforge-0.2.38 → processforge-0.3.1}/src/processforge.egg-info/dependency_links.txt +0 -0
  114. {processforge-0.2.38 → processforge-0.3.1}/src/processforge.egg-info/entry_points.txt +0 -0
  115. {processforge-0.2.38 → processforge-0.3.1}/src/processforge.egg-info/top_level.txt +0 -0
  116. {processforge-0.2.38 → processforge-0.3.1}/tests/test_festim_provider.py +0 -0
  117. {processforge-0.2.38 → processforge-0.3.1}/tests/test_providers.py +0 -0
  118. {processforge-0.2.38 → processforge-0.3.1}/tests/test_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.2.38
3
+ Version: 0.3.1
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,8 @@ 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
33
+ Requires-Dist: pip>=26.1.2
32
34
  Provides-Extra: eo
33
35
  Requires-Dist: pyomo>=6.7; extra == "eo"
34
36
  Provides-Extra: eo-casadi
@@ -111,7 +113,8 @@ uv tool install "processforge[modelica]"
111
113
  4. **Look at the output**
112
114
 
113
115
  `pf apply` writes results under `outputs/`:
114
- - `*_results.zarr` — simulation results store
116
+ - `*_results.zarr` — simulation results store (per-variable arrays, composition flattened)
117
+ - `*_results.zarr.schema.json` — schema file describing streams, variables, dtypes, units, shapes, and run provenance
115
118
  - `*_validation.xlsx` — validation report
116
119
  - `*.pfstate/` — versioned snapshot store with a `latest` pointer
117
120
 
@@ -126,8 +129,10 @@ fs = EOFlowsheet(config, backend="scipy")
126
129
  results = fs.run()
127
130
  ```
128
131
 
129
- ## Usage and Docker
130
- Processforge is also available as Docker images, and can be run in the cloud. See the [usage guide](docs/usage.md) for CLI commands, workflows, Docker, and cloud notes.
132
+ ## Usage
133
+ See the [usage guide](docs/usage.md) for CLI commands and workflows.
134
+
135
+ Individual providers (e.g. FESTIM, OpenMC) can be run via Docker images using the provider image contract.
131
136
 
132
137
  ## Flowsheets
133
138
  The core of Processforge is the flowsheet JSON format, which defines materials, units, and recycle rules. See below for more information on the formats and example flowsheets.
@@ -53,7 +53,8 @@ uv tool install "processforge[modelica]"
53
53
  4. **Look at the output**
54
54
 
55
55
  `pf apply` writes results under `outputs/`:
56
- - `*_results.zarr` — simulation results store
56
+ - `*_results.zarr` — simulation results store (per-variable arrays, composition flattened)
57
+ - `*_results.zarr.schema.json` — schema file describing streams, variables, dtypes, units, shapes, and run provenance
57
58
  - `*_validation.xlsx` — validation report
58
59
  - `*.pfstate/` — versioned snapshot store with a `latest` pointer
59
60
 
@@ -68,8 +69,10 @@ fs = EOFlowsheet(config, backend="scipy")
68
69
  results = fs.run()
69
70
  ```
70
71
 
71
- ## Usage and Docker
72
- Processforge is also available as Docker images, and can be run in the cloud. See the [usage guide](docs/usage.md) for CLI commands, workflows, Docker, and cloud notes.
72
+ ## Usage
73
+ See the [usage guide](docs/usage.md) for CLI commands and workflows.
74
+
75
+ Individual providers (e.g. FESTIM, OpenMC) can be run via Docker images using the provider image contract.
73
76
 
74
77
  ## Flowsheets
75
78
  The core of Processforge is the flowsheet JSON format, which defines materials, units, and recycle rules. See below for more information on the formats and example flowsheets.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "processforge"
7
- version = "0.2.38"
7
+ version = "0.3.1"
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,8 @@ dependencies = [
35
35
  "pandas>=2.2",
36
36
  "zarr>=3.0",
37
37
  "coolprop>=7.2.0",
38
+ "typer>=0.12",
39
+ "pip>=26.1.2",
38
40
  ]
39
41
 
40
42
  [project.optional-dependencies]
@@ -29,7 +29,7 @@ from .units.flash import Flash
29
29
  from .units.heater import Heater
30
30
  from .eo import EOFlowsheet, EOSolver
31
31
 
32
- __version__ = "0.1.0"
32
+ __version__ = "0.3.0"
33
33
 
34
34
  __all__ = [
35
35
  "Flowsheet",
@@ -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}")