processforge 0.2.34__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.

Files changed (118) hide show
  1. {processforge-0.2.34 → processforge-0.2.40}/PKG-INFO +37 -18
  2. processforge-0.2.40/README.md +82 -0
  3. {processforge-0.2.34 → processforge-0.2.40}/pyproject.toml +2 -3
  4. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/__init__.py +1 -1
  5. processforge-0.2.40/src/processforge/cli/__init__.py +44 -0
  6. processforge-0.2.40/src/processforge/cli/apply.py +207 -0
  7. processforge-0.2.40/src/processforge/cli/common.py +280 -0
  8. processforge-0.2.40/src/processforge/cli/diagram.py +57 -0
  9. processforge-0.2.40/src/processforge/cli/display.py +69 -0
  10. processforge-0.2.40/src/processforge/cli/export_fmu.py +41 -0
  11. processforge-0.2.40/src/processforge/cli/export_modelica.py +50 -0
  12. processforge-0.2.40/src/processforge/cli/init.py +171 -0
  13. processforge-0.2.40/src/processforge/cli/plan.py +132 -0
  14. processforge-0.2.40/src/processforge/cli/run.py +106 -0
  15. processforge-0.2.40/src/processforge/cli/validate.py +20 -0
  16. processforge-0.2.40/src/processforge/simulate.py +59 -0
  17. {processforge-0.2.34 → processforge-0.2.40}/src/processforge.egg-info/PKG-INFO +37 -18
  18. {processforge-0.2.34 → processforge-0.2.40}/src/processforge.egg-info/SOURCES.txt +12 -1
  19. {processforge-0.2.34 → processforge-0.2.40}/src/processforge.egg-info/requires.txt +1 -2
  20. processforge-0.2.40/tests/test_cli.py +380 -0
  21. {processforge-0.2.34 → processforge-0.2.40}/tests/test_init.py +9 -9
  22. processforge-0.2.34/README.md +0 -62
  23. processforge-0.2.34/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -202
  24. processforge-0.2.34/src/processforge/simulate.py +0 -930
  25. {processforge-0.2.34 → processforge-0.2.40}/LICENSE +0 -0
  26. {processforge-0.2.34 → processforge-0.2.40}/MANIFEST.in +0 -0
  27. {processforge-0.2.34 → processforge-0.2.40}/flowsheets/closed-loop-chain.json +0 -0
  28. {processforge-0.2.34 → processforge-0.2.40}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
  29. {processforge-0.2.34 → processforge-0.2.40}/flowsheets/hydraulic-chain.json +0 -0
  30. {processforge-0.2.34 → processforge-0.2.40}/flowsheets/openmc/msre_eigenvalue.json +0 -0
  31. {processforge-0.2.34 → processforge-0.2.40}/setup.cfg +0 -0
  32. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/__main__.py +0 -0
  33. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/_schema.py +0 -0
  34. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/analysis/__init__.py +0 -0
  35. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/analysis/dof.py +0 -0
  36. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/compose.py +0 -0
  37. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/__init__.py +0 -0
  38. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/backends/__init__.py +0 -0
  39. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/backends/base.py +0 -0
  40. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/backends/casadi_backend.py +0 -0
  41. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  42. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/backends/scipy_backend.py +0 -0
  43. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/flowsheet.py +0 -0
  44. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/jacobian.py +0 -0
  45. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/mixin.py +0 -0
  46. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/solver.py +0 -0
  47. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/stream_var.py +0 -0
  48. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/units/__init__.py +0 -0
  49. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/units/flash_eo.py +0 -0
  50. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/units/heater_eo.py +0 -0
  51. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/units/pipes_eo.py +0 -0
  52. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/units/pump_eo.py +0 -0
  53. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/units/strainer_eo.py +0 -0
  54. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/eo/units/valve_eo.py +0 -0
  55. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/flowsheet.py +0 -0
  56. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/fmu/__init__.py +0 -0
  57. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/fmu/_fmi_vars.py +0 -0
  58. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/fmu/builder.py +0 -0
  59. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/fmu/slave_template.py +0 -0
  60. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/lock.py +0 -0
  61. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/modelica/__init__.py +0 -0
  62. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/modelica/mo_writer.py +0 -0
  63. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/modelica/omc_runner.py +0 -0
  64. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/modelica/transpiler.py +0 -0
  65. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/modelica/unit_equations.py +0 -0
  66. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/pcl/__init__.py +0 -0
  67. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/pcl/compiler.py +0 -0
  68. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/pcl/namespace.py +0 -0
  69. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/provenance.py +0 -0
  70. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/__init__.py +0 -0
  71. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/base.py +0 -0
  72. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/base_jacobian_mixin.py +0 -0
  73. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/cantera_jacobian.py +0 -0
  74. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/cantera_provider.py +0 -0
  75. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/coolprop_provider.py +0 -0
  76. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/festim_provider.py +0 -0
  77. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/jacobian_contributor.py +0 -0
  78. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/manager.py +0 -0
  79. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/modelica_jacobian.py +0 -0
  80. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/modelica_provider.py +0 -0
  81. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/openmc_provider.py +0 -0
  82. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/reference_state_registry.py +0 -0
  83. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/providers/registry.py +0 -0
  84. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/result.py +0 -0
  85. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/schemas/__init__.py +0 -0
  86. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/schemas/flowsheet_schema.json +0 -0
  87. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/schemas/openmc/openmc_model.py +0 -0
  88. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/solver.py +0 -0
  89. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/state.py +0 -0
  90. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/thermo.py +0 -0
  91. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/types.py +0 -0
  92. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/__init__.py +0 -0
  93. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/cstr.py +0 -0
  94. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/flash.py +0 -0
  95. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/heater.py +0 -0
  96. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/pfr.py +0 -0
  97. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/pipes.py +0 -0
  98. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/provider_mixin.py +0 -0
  99. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/pump.py +0 -0
  100. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/registry.py +0 -0
  101. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/solver.py +0 -0
  102. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/solver_unit.py +0 -0
  103. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/strainer.py +0 -0
  104. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/tank.py +0 -0
  105. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/units/valve.py +0 -0
  106. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/utils/__init__.py +0 -0
  107. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/utils/flowsheet_diagram.py +0 -0
  108. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/utils/mermaid_diagram.py +0 -0
  109. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/utils/topology.py +0 -0
  110. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/utils/unit_consistency.py +0 -0
  111. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/utils/validate_flowsheet.py +0 -0
  112. {processforge-0.2.34 → processforge-0.2.40}/src/processforge/utils/validation.py +0 -0
  113. {processforge-0.2.34 → processforge-0.2.40}/src/processforge.egg-info/dependency_links.txt +0 -0
  114. {processforge-0.2.34 → processforge-0.2.40}/src/processforge.egg-info/entry_points.txt +0 -0
  115. {processforge-0.2.34 → processforge-0.2.40}/src/processforge.egg-info/top_level.txt +0 -0
  116. {processforge-0.2.34 → processforge-0.2.40}/tests/test_festim_provider.py +0 -0
  117. {processforge-0.2.34 → processforge-0.2.40}/tests/test_providers.py +0 -0
  118. {processforge-0.2.34 → 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.34
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,8 +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: pi>=0.1.2
33
- Requires-Dist: pip>=26.1.2
32
+ Requires-Dist: typer>=0.12
34
33
  Provides-Extra: eo
35
34
  Requires-Dist: pyomo>=6.7; extra == "eo"
36
35
  Provides-Extra: eo-casadi
@@ -66,38 +65,58 @@ A lightweight Python framework for process simulation, coupling hydraulic, therm
66
65
 
67
66
  ## Install
68
67
 
68
+ Install the `pf` command-line tool with [uv](https://docs.astral.sh/uv/):
69
+
69
70
  ```bash
70
- uv add processforge
71
+ uv tool install processforge
71
72
  ```
72
73
 
73
74
  For CoolProp-backed units:
74
75
 
75
76
  ```bash
76
- uv add "processforge[coolprop]"
77
+ uv tool install "processforge[coolprop]"
77
78
  ```
78
79
 
79
80
  Optional solver backends:
80
81
 
81
82
  ```bash
82
- uv add "processforge[eo]"
83
- uv add "processforge[eo-casadi]"
84
- uv add "processforge[modelica]"
83
+ uv tool install "processforge[eo]"
84
+ uv tool install "processforge[eo-casadi]"
85
+ uv tool install "processforge[modelica]"
85
86
  ```
86
87
 
87
88
  ## Quick start
88
89
 
89
- Use `plan` to validate and preview changes, then `apply` to solve and store a snapshot. For flowsheets without Tank units, use `apply` to solve with the EO solver.
90
+ 1. **Install the tool**
90
91
 
91
- ```bash
92
- pf init
93
- pf plan flowsheets/hydraulic-chain.json
94
- pf apply flowsheets/hydraulic-chain.json
95
- ```
96
- For flowsheets with Tank units, use `run` to solve with the SM solver. For direct run mode (for dynamic flowsheets with Tank units), use `run`:
92
+ ```bash
93
+ uv tool install processforge
94
+ ```
97
95
 
98
- ```bash
99
- pf run flowsheets/hydraulic-chain.json
100
- ```
96
+ 2. **Download an example flowsheet**
97
+
98
+ ```bash
99
+ curl -O https://raw.githubusercontent.com/urjanova/processforge/master/flowsheets/hydraulic-chain.json
100
+ ```
101
+
102
+ 3. **Initialize, plan, and apply**
103
+
104
+ ```bash
105
+ pf init
106
+ pf plan hydraulic-chain.json
107
+ pf apply hydraulic-chain.json
108
+ ```
109
+
110
+ `plan` validates the flowsheet (schema, DOF, units) without running the solver; `apply` solves it and stores a snapshot.
111
+
112
+ 4. **Look at the output**
113
+
114
+ `pf apply` writes results under `outputs/`:
115
+ - `*_results.zarr` — simulation results store
116
+ - `*_validation.xlsx` — validation report
117
+ - `*.pfstate/` — versioned snapshot store with a `latest` pointer
118
+
119
+ For flowsheets with Tank units (dynamic), use `pf run` instead of `pf apply` to solve with the SM solver.
101
120
 
102
121
  ## Python API
103
122
 
@@ -0,0 +1,82 @@
1
+ # Processforge
2
+
3
+ ![processforge-logo](images/processforge-logo.svg)
4
+
5
+ A lightweight Python framework for process simulation, coupling hydraulic, thermal, and reactor workflows.
6
+
7
+ ## Install
8
+
9
+ Install the `pf` command-line tool with [uv](https://docs.astral.sh/uv/):
10
+
11
+ ```bash
12
+ uv tool install processforge
13
+ ```
14
+
15
+ For CoolProp-backed units:
16
+
17
+ ```bash
18
+ uv tool install "processforge[coolprop]"
19
+ ```
20
+
21
+ Optional solver backends:
22
+
23
+ ```bash
24
+ uv tool install "processforge[eo]"
25
+ uv tool install "processforge[eo-casadi]"
26
+ uv tool install "processforge[modelica]"
27
+ ```
28
+
29
+ ## Quick start
30
+
31
+ 1. **Install the tool**
32
+
33
+ ```bash
34
+ uv tool install processforge
35
+ ```
36
+
37
+ 2. **Download an example flowsheet**
38
+
39
+ ```bash
40
+ curl -O https://raw.githubusercontent.com/urjanova/processforge/master/flowsheets/hydraulic-chain.json
41
+ ```
42
+
43
+ 3. **Initialize, plan, and apply**
44
+
45
+ ```bash
46
+ pf init
47
+ pf plan hydraulic-chain.json
48
+ pf apply hydraulic-chain.json
49
+ ```
50
+
51
+ `plan` validates the flowsheet (schema, DOF, units) without running the solver; `apply` solves it and stores a snapshot.
52
+
53
+ 4. **Look at the output**
54
+
55
+ `pf apply` writes results under `outputs/`:
56
+ - `*_results.zarr` — simulation results store
57
+ - `*_validation.xlsx` — validation report
58
+ - `*.pfstate/` — versioned snapshot store with a `latest` pointer
59
+
60
+ For flowsheets with Tank units (dynamic), use `pf run` instead of `pf apply` to solve with the SM solver.
61
+
62
+ ## Python API
63
+
64
+ ```python
65
+ from processforge import EOFlowsheet, validate_flowsheet
66
+ config = validate_flowsheet("flowsheets/hydraulic-chain.json")
67
+ fs = EOFlowsheet(config, backend="scipy")
68
+ results = fs.run()
69
+ ```
70
+
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.
73
+
74
+ ## Flowsheets
75
+ 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.
76
+ - [docs/flowsheets.md](docs/flowsheets.md) : flowsheet JSON format, materials, units, and recycle rules
77
+ - [flowsheets/](flowsheets/) : example flowsheets shipped with the repo
78
+
79
+
80
+ ## License
81
+
82
+ BSD 3-Clause License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "processforge"
7
- version = "0.2.34"
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,8 +35,7 @@ dependencies = [
35
35
  "pandas>=2.2",
36
36
  "zarr>=3.0",
37
37
  "coolprop>=7.2.0",
38
- "pi>=0.1.2",
39
- "pip>=26.1.2",
38
+ "typer>=0.12",
40
39
  ]
41
40
 
42
41
  [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.2.40"
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)