processforge 0.2.5__tar.gz → 0.2.7__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 (64) hide show
  1. {processforge-0.2.5/src/processforge.egg-info → processforge-0.2.7}/PKG-INFO +3 -1
  2. {processforge-0.2.5 → processforge-0.2.7}/pyproject.toml +4 -1
  3. processforge-0.2.7/src/processforge/fmu/__init__.py +3 -0
  4. processforge-0.2.7/src/processforge/fmu/_fmi_vars.py +143 -0
  5. processforge-0.2.7/src/processforge/fmu/builder.py +190 -0
  6. processforge-0.2.7/src/processforge/fmu/slave_template.py +252 -0
  7. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/simulate.py +33 -0
  8. {processforge-0.2.5 → processforge-0.2.7/src/processforge.egg-info}/PKG-INFO +3 -1
  9. {processforge-0.2.5 → processforge-0.2.7}/src/processforge.egg-info/SOURCES.txt +4 -0
  10. {processforge-0.2.5 → processforge-0.2.7}/src/processforge.egg-info/requires.txt +3 -0
  11. {processforge-0.2.5 → processforge-0.2.7}/LICENSE +0 -0
  12. {processforge-0.2.5 → processforge-0.2.7}/MANIFEST.in +0 -0
  13. {processforge-0.2.5 → processforge-0.2.7}/README.md +0 -0
  14. {processforge-0.2.5 → processforge-0.2.7}/flowsheets/archive/example_dynamic_hybrid.json +0 -0
  15. {processforge-0.2.5 → processforge-0.2.7}/flowsheets/archive/example_dynamic_tank.json +0 -0
  16. {processforge-0.2.5 → processforge-0.2.7}/flowsheets/archive/example_flash.json +0 -0
  17. {processforge-0.2.5 → processforge-0.2.7}/flowsheets/archive/hydraulic_chain.json +0 -0
  18. {processforge-0.2.5 → processforge-0.2.7}/flowsheets/closed-loop-chain.json +0 -0
  19. {processforge-0.2.5 → processforge-0.2.7}/flowsheets/hydraulic-chain.json +0 -0
  20. {processforge-0.2.5 → processforge-0.2.7}/setup.cfg +0 -0
  21. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/__init__.py +0 -0
  22. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/_schema.py +0 -0
  23. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/__init__.py +0 -0
  24. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/backends/__init__.py +0 -0
  25. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/backends/base.py +0 -0
  26. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/backends/casadi_backend.py +0 -0
  27. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  28. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/backends/scipy_backend.py +0 -0
  29. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/flowsheet.py +0 -0
  30. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/jacobian.py +0 -0
  31. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/mixin.py +0 -0
  32. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/solver.py +0 -0
  33. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/stream_var.py +0 -0
  34. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/units/__init__.py +0 -0
  35. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/units/flash_eo.py +0 -0
  36. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/units/heater_eo.py +0 -0
  37. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/units/pipes_eo.py +0 -0
  38. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/units/pump_eo.py +0 -0
  39. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/units/strainer_eo.py +0 -0
  40. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/eo/units/valve_eo.py +0 -0
  41. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/flowsheet.py +0 -0
  42. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/provenance.py +0 -0
  43. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/result.py +0 -0
  44. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/schemas/__init__.py +0 -0
  45. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/schemas/flowsheet_schema.json +0 -0
  46. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/solver.py +0 -0
  47. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/thermo.py +0 -0
  48. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/units/__init__.py +0 -0
  49. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/units/flash.py +0 -0
  50. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/units/heater.py +0 -0
  51. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/units/pipes.py +0 -0
  52. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/units/pump.py +0 -0
  53. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/units/solver.py +0 -0
  54. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/units/strainer.py +0 -0
  55. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/units/tank.py +0 -0
  56. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/units/valve.py +0 -0
  57. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/utils/__init__.py +0 -0
  58. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/utils/flowsheet_diagram.py +0 -0
  59. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/utils/validate_flowsheet.py +0 -0
  60. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/utils/validation.py +0 -0
  61. {processforge-0.2.5 → processforge-0.2.7}/src/processforge/validate.py +0 -0
  62. {processforge-0.2.5 → processforge-0.2.7}/src/processforge.egg-info/dependency_links.txt +0 -0
  63. {processforge-0.2.5 → processforge-0.2.7}/src/processforge.egg-info/entry_points.txt +0 -0
  64. {processforge-0.2.5 → processforge-0.2.7}/src/processforge.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: A Python-based process simulation framework for chemical engineering applications.
5
5
  Author-email: Process Forge Team <team@processforge.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -34,6 +34,8 @@ Requires-Dist: pyomo>=6.7; extra == "eo"
34
34
  Provides-Extra: eo-casadi
35
35
  Requires-Dist: pyomo>=6.7; extra == "eo-casadi"
36
36
  Requires-Dist: casadi>=3.6; extra == "eo-casadi"
37
+ Provides-Extra: fmu
38
+ Requires-Dist: pythonfmu>=0.6; extra == "fmu"
37
39
  Provides-Extra: dev
38
40
  Requires-Dist: pytest>=7.0; extra == "dev"
39
41
  Requires-Dist: black; extra == "dev"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "processforge"
7
- version = "0.2.5"
7
+ version = "0.2.7"
8
8
  description = "A Python-based process simulation framework for chemical engineering applications."
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -45,6 +45,9 @@ eo-casadi = [
45
45
  "pyomo>=6.7",
46
46
  "casadi>=3.6",
47
47
  ]
48
+ fmu = [
49
+ "pythonfmu>=0.6",
50
+ ]
48
51
  dev = [
49
52
  "pytest>=7.0",
50
53
  "black",
@@ -0,0 +1,3 @@
1
+ from .builder import build_fmu
2
+
3
+ __all__ = ["build_fmu"]
@@ -0,0 +1,143 @@
1
+ """FMI variable spec generation for ProcessForge FMU export."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+
7
+ def _sanitize_name(s: str) -> str:
8
+ """Replace characters invalid in Python identifiers with underscores."""
9
+ return re.sub(r"[^a-zA-Z0-9_]", "_", s)
10
+
11
+
12
+ def get_fmi_variable_specs(
13
+ feed_streams: list[str],
14
+ output_streams: list[str],
15
+ components: list[str],
16
+ unit_params: dict[str, dict],
17
+ config: dict,
18
+ mode: str,
19
+ ) -> list[dict]:
20
+ """Return ordered list of FMI variable spec dicts for a flowsheet.
21
+
22
+ Each dict contains:
23
+ attr_name – Python attribute name on the slave instance
24
+ initial_value – float initial value
25
+ causality – "input" | "output" | "parameter"
26
+ variability – "continuous" | "fixed"
27
+ description – human-readable string
28
+ """
29
+ specs: list[dict] = []
30
+
31
+ # --- Inputs: feed stream boundary conditions ---
32
+ for stream_name in feed_streams:
33
+ stream_cfg = config["streams"][stream_name]
34
+ safe_s = _sanitize_name(stream_name)
35
+
36
+ specs.append({
37
+ "attr_name": f"feed_{safe_s}_T",
38
+ "initial_value": float(stream_cfg.get("T", 298.15)),
39
+ "causality": "input",
40
+ "variability": "continuous",
41
+ "description": f"Temperature of feed stream '{stream_name}' [K]",
42
+ })
43
+ specs.append({
44
+ "attr_name": f"feed_{safe_s}_P",
45
+ "initial_value": float(stream_cfg.get("P", 101325.0)),
46
+ "causality": "input",
47
+ "variability": "continuous",
48
+ "description": f"Pressure of feed stream '{stream_name}' [Pa]",
49
+ })
50
+ specs.append({
51
+ "attr_name": f"feed_{safe_s}_flowrate",
52
+ "initial_value": float(stream_cfg.get("flowrate", 1.0)),
53
+ "causality": "input",
54
+ "variability": "continuous",
55
+ "description": f"Molar flowrate of feed stream '{stream_name}' [mol/s]",
56
+ })
57
+ z_cfg = stream_cfg.get("z", {})
58
+ for comp in components:
59
+ safe_c = _sanitize_name(comp)
60
+ specs.append({
61
+ "attr_name": f"feed_{safe_s}_z_{safe_c}",
62
+ "initial_value": float(z_cfg.get(comp, 0.0)),
63
+ "causality": "input",
64
+ "variability": "continuous",
65
+ "description": f"Mole fraction of {comp} in feed stream '{stream_name}'",
66
+ })
67
+
68
+ # --- Outputs: calculated stream properties ---
69
+ for stream_name in output_streams:
70
+ safe_s = _sanitize_name(stream_name)
71
+
72
+ specs.append({
73
+ "attr_name": f"out_{safe_s}_T",
74
+ "initial_value": 0.0,
75
+ "causality": "output",
76
+ "variability": "continuous",
77
+ "description": f"Temperature of stream '{stream_name}' [K]",
78
+ })
79
+ specs.append({
80
+ "attr_name": f"out_{safe_s}_P",
81
+ "initial_value": 0.0,
82
+ "causality": "output",
83
+ "variability": "continuous",
84
+ "description": f"Pressure of stream '{stream_name}' [Pa]",
85
+ })
86
+ specs.append({
87
+ "attr_name": f"out_{safe_s}_flowrate",
88
+ "initial_value": 0.0,
89
+ "causality": "output",
90
+ "variability": "continuous",
91
+ "description": f"Molar flowrate of stream '{stream_name}' [mol/s]",
92
+ })
93
+ for comp in components:
94
+ safe_c = _sanitize_name(comp)
95
+ specs.append({
96
+ "attr_name": f"out_{safe_s}_z_{safe_c}",
97
+ "initial_value": 0.0,
98
+ "causality": "output",
99
+ "variability": "continuous",
100
+ "description": f"Mole fraction of {comp} in stream '{stream_name}'",
101
+ })
102
+
103
+ # --- Parameters: unit design values (fixed for FMU lifetime) ---
104
+ for unit_name, params in unit_params.items():
105
+ safe_u = _sanitize_name(unit_name)
106
+ for key, value in params.items():
107
+ if not isinstance(value, (int, float)):
108
+ continue
109
+ safe_k = _sanitize_name(key)
110
+ specs.append({
111
+ "attr_name": f"param_{safe_u}_{safe_k}",
112
+ "initial_value": float(value),
113
+ "causality": "parameter",
114
+ "variability": "fixed",
115
+ "description": f"Parameter '{key}' of unit '{unit_name}'",
116
+ })
117
+
118
+ # --- Tank state outputs (dynamic mode only) ---
119
+ if mode == "dynamic":
120
+ for unit_name, unit_cfg in config["units"].items():
121
+ if unit_cfg.get("type") != "Tank":
122
+ continue
123
+ safe_u = _sanitize_name(unit_name)
124
+ initial_T = float(unit_cfg.get("initial_T", 298.15))
125
+ specs.append({
126
+ "attr_name": f"state_{safe_u}_T",
127
+ "initial_value": initial_T,
128
+ "causality": "output",
129
+ "variability": "continuous",
130
+ "description": f"Tank temperature in unit '{unit_name}' [K]",
131
+ })
132
+ initial_n = unit_cfg.get("initial_n", {})
133
+ for comp in components:
134
+ safe_c = _sanitize_name(comp)
135
+ specs.append({
136
+ "attr_name": f"state_{safe_u}_n_{safe_c}",
137
+ "initial_value": float(initial_n.get(comp, 0.0)),
138
+ "causality": "output",
139
+ "variability": "continuous",
140
+ "description": f"Molar holdup of {comp} in tank '{unit_name}' [mol]",
141
+ })
142
+
143
+ return specs
@@ -0,0 +1,190 @@
1
+ """Build a PythonFMU co-simulation FMU from a ProcessForge flowsheet config."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import tempfile
9
+
10
+ from ..utils.validate_flowsheet import validate_flowsheet
11
+ from ._fmi_vars import _sanitize_name
12
+ from .slave_template import render_slave_source
13
+
14
+
15
+ def build_fmu(
16
+ config_path: str,
17
+ output_dir: str = "outputs",
18
+ backend: str = "scipy",
19
+ ) -> str:
20
+ """Build an FMI 2.0 co-simulation FMU from a ProcessForge flowsheet JSON.
21
+
22
+ Args:
23
+ config_path: Path to the flowsheet JSON file.
24
+ output_dir: Directory where the ``.fmu`` file will be written.
25
+ backend: EO solver backend for steady-state mode
26
+ (``"scipy"``, ``"pyomo"``, or ``"casadi"``).
27
+
28
+ Returns:
29
+ Absolute path to the generated ``.fmu`` file.
30
+
31
+ Raises:
32
+ RuntimeError: If ``pythonfmu`` is not installed or the build fails.
33
+ """
34
+ if not shutil.which("pythonfmu"):
35
+ raise RuntimeError(
36
+ "PythonFMU is not installed or not on PATH. "
37
+ "Install it with: pip install processforge[fmu]"
38
+ )
39
+
40
+ config = validate_flowsheet(config_path)
41
+ interface = _analyze_config(config)
42
+ slave_class_name = _get_slave_class_name(config, config_path)
43
+
44
+ os.makedirs(output_dir, exist_ok=True)
45
+
46
+ with tempfile.TemporaryDirectory() as staging_dir:
47
+ # Place the config JSON in the staging directory so PythonFMU bundles
48
+ # it into the FMU's resources/ directory.
49
+ config_staging = os.path.join(staging_dir, "flowsheet_config.json")
50
+ shutil.copy(config_path, config_staging)
51
+
52
+ slave_source = render_slave_source(
53
+ slave_class_name=slave_class_name,
54
+ mode=interface["mode"],
55
+ backend=backend,
56
+ feed_streams=interface["feed_streams"],
57
+ output_streams=interface["output_streams"],
58
+ components=interface["components"],
59
+ unit_params=interface["unit_params"],
60
+ config=config,
61
+ )
62
+
63
+ slave_py = os.path.join(staging_dir, f"{slave_class_name}.py")
64
+ with open(slave_py, "w") as f:
65
+ f.write(slave_source)
66
+
67
+ try:
68
+ result = subprocess.run(
69
+ [
70
+ "pythonfmu",
71
+ "build",
72
+ "-f", slave_py,
73
+ "-d", os.path.abspath(output_dir),
74
+ config_staging,
75
+ ],
76
+ check=True,
77
+ capture_output=True,
78
+ text=True,
79
+ )
80
+ except subprocess.CalledProcessError as exc:
81
+ raise RuntimeError(
82
+ f"pythonfmu build failed:\n{exc.stderr}"
83
+ ) from exc
84
+
85
+ fmu_path = os.path.abspath(
86
+ os.path.join(output_dir, f"{slave_class_name}.fmu")
87
+ )
88
+ return fmu_path
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Config analysis
93
+ # ---------------------------------------------------------------------------
94
+
95
+ def _analyze_config(config: dict) -> dict:
96
+ """Extract the FMI interface description from a validated config dict.
97
+
98
+ Returns a dict with:
99
+ feed_streams – list of feed stream names (from config["streams"])
100
+ output_streams – all unit outlet stream names (not feeds)
101
+ components – sorted list of component names
102
+ unit_params – {unit_name: {param_key: value}} for numeric params
103
+ mode – "steady" or "dynamic"
104
+ """
105
+ feed_streams: list[str] = list(config["streams"].keys())
106
+
107
+ # Collect all outlet stream names from unit definitions
108
+ all_outlet_streams: set[str] = set()
109
+ for unit_cfg in config["units"].values():
110
+ for outlet in _get_outlets(unit_cfg):
111
+ all_outlet_streams.add(outlet)
112
+
113
+ # Output streams = outlets not already in feeds
114
+ feed_set = set(feed_streams)
115
+ output_streams: list[str] = [s for s in all_outlet_streams if s not in feed_set]
116
+ # Preserve stable order (insertion order of config["units"])
117
+ seen: set[str] = set()
118
+ output_streams_ordered: list[str] = []
119
+ for unit_cfg in config["units"].values():
120
+ for outlet in _get_outlets(unit_cfg):
121
+ if outlet not in feed_set and outlet not in seen:
122
+ output_streams_ordered.append(outlet)
123
+ seen.add(outlet)
124
+
125
+ # Component discovery — same logic as EOFlowsheet._collect_components()
126
+ comp_set: set[str] = set()
127
+ for stream in config["streams"].values():
128
+ comp_set.update(stream.get("z", {}).keys())
129
+ components = sorted(comp_set)
130
+
131
+ # Unit parameters — numeric config keys, excluding topology keys
132
+ _topology_keys = {"type", "in", "out", "out_liq", "out_vap"}
133
+ unit_params: dict[str, dict] = {}
134
+ for unit_name, unit_cfg in config["units"].items():
135
+ params = {
136
+ k: v
137
+ for k, v in unit_cfg.items()
138
+ if k not in _topology_keys and isinstance(v, (int, float))
139
+ }
140
+ if params:
141
+ unit_params[unit_name] = params
142
+
143
+ # Simulation mode — force "dynamic" if any Tank is present
144
+ mode = config.get("simulation", {}).get("mode", "steady")
145
+ has_tank = any(
146
+ cfg.get("type") == "Tank" for cfg in config["units"].values()
147
+ )
148
+ if has_tank:
149
+ mode = "dynamic"
150
+
151
+ return {
152
+ "feed_streams": feed_streams,
153
+ "output_streams": output_streams_ordered,
154
+ "components": components,
155
+ "unit_params": unit_params,
156
+ "mode": mode,
157
+ }
158
+
159
+
160
+ def _get_outlets(unit_cfg: dict) -> list[str]:
161
+ """Return outlet stream name(s) — handles Flash (out_vap + out_liq)."""
162
+ if unit_cfg.get("type") == "Flash":
163
+ outlets = []
164
+ if unit_cfg.get("out_liq"):
165
+ outlets.append(unit_cfg["out_liq"])
166
+ if unit_cfg.get("out_vap"):
167
+ outlets.append(unit_cfg["out_vap"])
168
+ return outlets
169
+ out = unit_cfg.get("out")
170
+ return [out] if out else []
171
+
172
+
173
+ def _get_slave_class_name(config: dict, config_path: str) -> str:
174
+ """Derive a valid Python class name for the FMU slave."""
175
+ name = config.get("metadata", {}).get("name", "")
176
+ if not name:
177
+ name = os.path.splitext(os.path.basename(config_path))[0]
178
+
179
+ # Convert to PascalCase-safe identifier
180
+ name = re.sub(r"[^a-zA-Z0-9]", "_", name)
181
+ name = re.sub(r"_+", "_", name).strip("_")
182
+ # Ensure starts with a letter
183
+ if name and name[0].isdigit():
184
+ name = "FMU_" + name
185
+ if not name:
186
+ name = "ProcessForgeFMU"
187
+
188
+ # PascalCase: capitalise each word segment
189
+ name = "".join(part.capitalize() for part in name.split("_"))
190
+ return name or "ProcessForgeFMU"
@@ -0,0 +1,252 @@
1
+ """Generate per-flowsheet FMU slave source code for PythonFMU."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ from ._fmi_vars import _sanitize_name, get_fmi_variable_specs
7
+
8
+
9
+ def render_slave_source(
10
+ slave_class_name: str,
11
+ mode: str,
12
+ backend: str,
13
+ feed_streams: list[str],
14
+ output_streams: list[str],
15
+ components: list[str],
16
+ unit_params: dict[str, dict],
17
+ config: dict,
18
+ ) -> str:
19
+ """Return complete Python source for the per-flowsheet FMU slave.
20
+
21
+ The generated file is stand-alone: ``_sanitize_name`` is embedded so the
22
+ slave does not depend on processforge internals for name resolution.
23
+ """
24
+ specs = get_fmi_variable_specs(
25
+ feed_streams, output_streams, components, unit_params, config, mode
26
+ )
27
+
28
+ lines: list[str] = []
29
+
30
+ # ------------------------------------------------------------------ header
31
+ lines += [
32
+ "# AUTO-GENERATED by processforge.fmu.slave_template — DO NOT EDIT",
33
+ "from __future__ import annotations",
34
+ "import json",
35
+ "import os",
36
+ "import re",
37
+ "from copy import deepcopy",
38
+ "from pythonfmu import Fmi2Slave, Fmi2Causality, Fmi2Variability, Real",
39
+ "",
40
+ "",
41
+ "def _sanitize_name(s: str) -> str:",
42
+ ' return re.sub(r"[^a-zA-Z0-9_]", "_", s)',
43
+ "",
44
+ "",
45
+ f"class {slave_class_name}(Fmi2Slave):",
46
+ "",
47
+ " def __init__(self, **kwargs):",
48
+ " super().__init__(**kwargs)",
49
+ " _cfg_path = os.path.join(self.resources, 'flowsheet_config.json')",
50
+ " with open(_cfg_path) as _f:",
51
+ " self._config = json.load(_f)",
52
+ "",
53
+ ]
54
+
55
+ # -------------------------------------------------------- instance attrs
56
+ lines.append(" # --- instance attribute initial values ---")
57
+ for spec in specs:
58
+ lines.append(f" self.{spec['attr_name']} = {spec['initial_value']!r}")
59
+
60
+ lines.append("")
61
+
62
+ # -------------------------------------------------------- register_variable
63
+ lines.append(" # --- FMI variable registration ---")
64
+ _causality_map = {
65
+ "input": "Fmi2Causality.input",
66
+ "output": "Fmi2Causality.output",
67
+ "parameter": "Fmi2Causality.parameter",
68
+ }
69
+ _variability_map = {
70
+ "continuous": "Fmi2Variability.continuous",
71
+ "fixed": "Fmi2Variability.fixed",
72
+ }
73
+ for spec in specs:
74
+ caus = _causality_map[spec["causality"]]
75
+ var = _variability_map[spec["variability"]]
76
+ desc = spec["description"].replace("'", "\\'")
77
+ lines.append(
78
+ f" self.register_variable(Real("
79
+ f"'{spec['attr_name']}', causality={caus}, "
80
+ f"variability={var}, "
81
+ f"description='{desc}'))"
82
+ )
83
+
84
+ lines.append("")
85
+
86
+ # -------------------------------------------------------- embedded metadata
87
+ components_repr = repr(components)
88
+ feed_streams_repr = repr(feed_streams)
89
+ output_streams_repr = repr(output_streams)
90
+ # unit_params keys used for writing params back into config
91
+ unit_param_keys_repr = repr({k: list(v.keys()) for k, v in unit_params.items()})
92
+ tank_units = [n for n, c in config["units"].items() if c.get("type") == "Tank"]
93
+ tank_units_repr = repr(tank_units)
94
+
95
+ lines += [
96
+ " self._components = " + components_repr,
97
+ " self._feed_stream_names = " + feed_streams_repr,
98
+ " self._output_stream_names = " + output_streams_repr,
99
+ " self._unit_param_keys = " + unit_param_keys_repr,
100
+ " self._tank_units = " + tank_units_repr,
101
+ "",
102
+ ]
103
+
104
+ if mode == "dynamic":
105
+ lines += _render_dynamic_init(tank_units, components)
106
+
107
+ # ---------------------------------------------------------- do_step
108
+ lines.append(" def do_step(self, current_time: float, step_size: float) -> bool:")
109
+
110
+ if mode == "steady":
111
+ lines += _render_steady_do_step(backend)
112
+ else:
113
+ lines += _render_dynamic_do_step()
114
+
115
+ lines.append("")
116
+
117
+ return "\n".join(lines)
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Steady-state do_step body
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def _render_steady_do_step(backend: str) -> list[str]:
125
+ return [
126
+ f" from processforge.eo import EOFlowsheet",
127
+ " config = deepcopy(self._config)",
128
+ "",
129
+ " # Write FMI inputs → feed stream conditions",
130
+ " for _sn in self._feed_stream_names:",
131
+ " _ss = _sanitize_name(_sn)",
132
+ " config['streams'][_sn]['T'] = getattr(self, f'feed_{_ss}_T')",
133
+ " config['streams'][_sn]['P'] = getattr(self, f'feed_{_ss}_P')",
134
+ " config['streams'][_sn]['flowrate'] = getattr(self, f'feed_{_ss}_flowrate')",
135
+ " for _c in self._components:",
136
+ " _sc = _sanitize_name(_c)",
137
+ " config['streams'][_sn]['z'][_c] = getattr(self, f'feed_{_ss}_z_{_sc}')",
138
+ "",
139
+ " # Write FMI parameters → unit design values",
140
+ " for _un, _pkeys in self._unit_param_keys.items():",
141
+ " _su = _sanitize_name(_un)",
142
+ " for _pk in _pkeys:",
143
+ " _spk = _sanitize_name(_pk)",
144
+ " _attr = f'param_{_su}_{_spk}'",
145
+ " if hasattr(self, _attr):",
146
+ " config['units'][_un][_pk] = getattr(self, _attr)",
147
+ "",
148
+ f" _fs = EOFlowsheet(config, backend='{backend}')",
149
+ " try:",
150
+ " _results = _fs.run()",
151
+ " except Exception:",
152
+ " return False",
153
+ "",
154
+ " # Write solver results → FMI outputs",
155
+ " for _sn in self._output_stream_names:",
156
+ " _ss = _sanitize_name(_sn)",
157
+ " _stream = _results.get(_sn, {})",
158
+ " setattr(self, f'out_{_ss}_T', float(_stream.get('T', 0.0)))",
159
+ " setattr(self, f'out_{_ss}_P', float(_stream.get('P', 0.0)))",
160
+ " setattr(self, f'out_{_ss}_flowrate', float(_stream.get('flowrate', 0.0)))",
161
+ " for _c in self._components:",
162
+ " _sc = _sanitize_name(_c)",
163
+ " setattr(self, f'out_{_ss}_z_{_sc}',",
164
+ " float(_stream.get('z', {}).get(_c, 0.0)))",
165
+ " return True",
166
+ ]
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Dynamic __init__ tail and do_step body
171
+ # ---------------------------------------------------------------------------
172
+
173
+ def _render_dynamic_init(tank_units: list[str], components: list[str]) -> list[str]:
174
+ lines = [
175
+ " # --- Build Flowsheet once for dynamic stepping ---",
176
+ " from processforge.flowsheet import Flowsheet as _Flowsheet",
177
+ " self._flowsheet = _Flowsheet(self._config)",
178
+ " self._flowsheet.build_units()",
179
+ " self._processing_order = self._flowsheet._get_processing_order()",
180
+ "",
181
+ " # Initialise Tank state from config",
182
+ " self._tank_states = {}",
183
+ " for _un in self._tank_units:",
184
+ " _unit = self._flowsheet.units[_un]",
185
+ " _ucfg = self._config['units'][_un]",
186
+ " _init_n = _ucfg.get('initial_n', {})",
187
+ " self._tank_states[_un] = {",
188
+ " 'n': {_c: float(_init_n.get(_c, 0.0)) for _c in self._components},",
189
+ " 'T': float(_ucfg.get('initial_T', 298.15)),",
190
+ " }",
191
+ "",
192
+ ]
193
+ return lines
194
+
195
+
196
+ def _render_dynamic_do_step() -> list[str]:
197
+ return [
198
+ " # Build feed snapshots from FMI inputs",
199
+ " _current = {}",
200
+ " for _sn in self._feed_stream_names:",
201
+ " _ss = _sanitize_name(_sn)",
202
+ " _current[_sn] = {",
203
+ " 'T': getattr(self, f'feed_{_ss}_T'),",
204
+ " 'P': getattr(self, f'feed_{_ss}_P'),",
205
+ " 'flowrate': getattr(self, f'feed_{_ss}_flowrate'),",
206
+ " 'z': {_c: getattr(self, f'feed_{_ss}_z_{_sanitize_name(_c)}')",
207
+ " for _c in self._components},",
208
+ " }",
209
+ "",
210
+ " _components_set = set(self._components)",
211
+ "",
212
+ " # Process units in topological order",
213
+ " for _un in self._processing_order:",
214
+ " _unit = self._flowsheet.units[_un]",
215
+ " _ucfg = self._config['units'][_un]",
216
+ " _inlet = self._flowsheet._get_merged_inlet(_current, _ucfg)",
217
+ "",
218
+ " if _ucfg['type'] == 'Tank':",
219
+ " _outlet, self._tank_states[_un] = \\",
220
+ " self._flowsheet._integrate_tank_step(",
221
+ " _unit, _inlet, self._tank_states[_un],",
222
+ " step_size, _components_set",
223
+ " )",
224
+ " else:",
225
+ " _outlet = _unit.run(_inlet)",
226
+ "",
227
+ " _outlet_name = _ucfg['out']",
228
+ " _current[_outlet_name] = _outlet",
229
+ "",
230
+ " # Write stream results → FMI outputs",
231
+ " for _sn in self._output_stream_names:",
232
+ " _ss = _sanitize_name(_sn)",
233
+ " _stream = _current.get(_sn, {})",
234
+ " setattr(self, f'out_{_ss}_T', float(_stream.get('T', 0.0)))",
235
+ " setattr(self, f'out_{_ss}_P', float(_stream.get('P', 0.0)))",
236
+ " setattr(self, f'out_{_ss}_flowrate', float(_stream.get('flowrate', 0.0)))",
237
+ " for _c in self._components:",
238
+ " _sc = _sanitize_name(_c)",
239
+ " setattr(self, f'out_{_ss}_z_{_sc}',",
240
+ " float(_stream.get('z', {}).get(_c, 0.0)))",
241
+ "",
242
+ " # Write Tank state outputs",
243
+ " for _un, _state in self._tank_states.items():",
244
+ " _su = _sanitize_name(_un)",
245
+ " setattr(self, f'state_{_su}_T', float(_state['T']))",
246
+ " for _c in self._components:",
247
+ " _sc = _sanitize_name(_c)",
248
+ " setattr(self, f'state_{_su}_n_{_sc}',",
249
+ " float(_state['n'].get(_c, 0.0)))",
250
+ "",
251
+ " return True",
252
+ ]
@@ -83,6 +83,26 @@ def _cmd_validate(args):
83
83
  raise SystemExit(1)
84
84
 
85
85
 
86
+ def _cmd_export_fmu(args):
87
+ """Export a flowsheet as an FMI 2.0 co-simulation FMU."""
88
+ fname = args.flowsheet
89
+ if not os.path.exists(fname):
90
+ logger.error(f"Flowsheet file '{fname}' not found.")
91
+ raise SystemExit(1)
92
+
93
+ from .fmu import build_fmu # local import — pythonfmu is optional
94
+
95
+ output_dir = args.output_dir or "outputs"
96
+ backend = args.backend or "scipy"
97
+
98
+ try:
99
+ fmu_path = build_fmu(fname, output_dir=output_dir, backend=backend)
100
+ logger.info(f"FMU written to: {fmu_path}")
101
+ except Exception as e:
102
+ logger.error(f"FMU export failed: {e}")
103
+ raise SystemExit(1)
104
+
105
+
86
106
  def _cmd_diagram(args):
87
107
  """Generate a flowsheet diagram from a JSON file."""
88
108
  fname = args.flowsheet
@@ -132,6 +152,18 @@ def main():
132
152
  diagram_parser.add_argument("--output-dir", "-o", default=".", help="Output directory (default: current directory)")
133
153
  diagram_parser.add_argument("--format", "-f", default="png", choices=["png", "svg", "pdf"], help="Output format (default: png)")
134
154
 
155
+ # processforge export-fmu
156
+ fmu_parser = subparsers.add_parser("export-fmu", help="Export flowsheet as FMI 2.0 co-simulation FMU")
157
+ fmu_parser.add_argument("flowsheet", help="Path to the flowsheet JSON file")
158
+ fmu_parser.add_argument(
159
+ "--output-dir", "-o", default="outputs",
160
+ help="Directory for the output FMU (default: outputs/)",
161
+ )
162
+ fmu_parser.add_argument(
163
+ "--backend", choices=["scipy", "pyomo", "casadi"], default="scipy",
164
+ help="EO solver backend for steady-state mode (default: scipy)",
165
+ )
166
+
135
167
  args = parser.parse_args()
136
168
 
137
169
  if args.command is None:
@@ -142,6 +174,7 @@ def main():
142
174
  "run": _cmd_run,
143
175
  "validate": _cmd_validate,
144
176
  "diagram": _cmd_diagram,
177
+ "export-fmu": _cmd_export_fmu,
145
178
  }
146
179
  commands[args.command](args)
147
180
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: A Python-based process simulation framework for chemical engineering applications.
5
5
  Author-email: Process Forge Team <team@processforge.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -34,6 +34,8 @@ Requires-Dist: pyomo>=6.7; extra == "eo"
34
34
  Provides-Extra: eo-casadi
35
35
  Requires-Dist: pyomo>=6.7; extra == "eo-casadi"
36
36
  Requires-Dist: casadi>=3.6; extra == "eo-casadi"
37
+ Provides-Extra: fmu
38
+ Requires-Dist: pythonfmu>=0.6; extra == "fmu"
37
39
  Provides-Extra: dev
38
40
  Requires-Dist: pytest>=7.0; extra == "dev"
39
41
  Requires-Dist: black; extra == "dev"
@@ -41,6 +41,10 @@ src/processforge/eo/units/pipes_eo.py
41
41
  src/processforge/eo/units/pump_eo.py
42
42
  src/processforge/eo/units/strainer_eo.py
43
43
  src/processforge/eo/units/valve_eo.py
44
+ src/processforge/fmu/__init__.py
45
+ src/processforge/fmu/_fmi_vars.py
46
+ src/processforge/fmu/builder.py
47
+ src/processforge/fmu/slave_template.py
44
48
  src/processforge/schemas/__init__.py
45
49
  src/processforge/schemas/flowsheet_schema.json
46
50
  src/processforge/units/__init__.py
@@ -26,3 +26,6 @@ pyomo>=6.7
26
26
  [eo-casadi]
27
27
  pyomo>=6.7
28
28
  casadi>=3.6
29
+
30
+ [fmu]
31
+ pythonfmu>=0.6
File without changes
File without changes
File without changes
File without changes