processforge 0.3.26__tar.gz → 0.4.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 (152) hide show
  1. {processforge-0.3.26/src/processforge.egg-info → processforge-0.4.1}/PKG-INFO +5 -1
  2. {processforge-0.3.26 → processforge-0.4.1}/pyproject.toml +6 -1
  3. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/__init__.py +1 -1
  4. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/cli/__init__.py +1 -1
  5. processforge-0.4.1/src/processforge/cli/export_fmu.py +88 -0
  6. processforge-0.4.1/src/processforge/cli/runs.py +201 -0
  7. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/fmu/_fmi_vars.py +35 -2
  8. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/fmu/builder.py +60 -3
  9. processforge-0.4.1/src/processforge/fmu/interface.py +214 -0
  10. processforge-0.4.1/src/processforge/fmu/slave_template.py +572 -0
  11. processforge-0.4.1/src/processforge/pathsim/__init__.py +6 -0
  12. processforge-0.4.1/src/processforge/pathsim/adapter.py +184 -0
  13. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/result.py +135 -0
  14. {processforge-0.3.26 → processforge-0.4.1/src/processforge.egg-info}/PKG-INFO +5 -1
  15. {processforge-0.3.26 → processforge-0.4.1}/src/processforge.egg-info/SOURCES.txt +5 -0
  16. {processforge-0.3.26 → processforge-0.4.1}/src/processforge.egg-info/requires.txt +5 -0
  17. {processforge-0.3.26 → processforge-0.4.1}/tests/test_cli.py +14 -10
  18. processforge-0.4.1/tests/test_fmu_solverunit.py +281 -0
  19. processforge-0.4.1/tests/test_pathsim_adapter.py +66 -0
  20. {processforge-0.3.26 → processforge-0.4.1}/tests/test_runs_command.py +27 -4
  21. processforge-0.3.26/src/processforge/cli/export_fmu.py +0 -41
  22. processforge-0.3.26/src/processforge/cli/runs.py +0 -88
  23. processforge-0.3.26/src/processforge/fmu/slave_template.py +0 -252
  24. {processforge-0.3.26 → processforge-0.4.1}/LICENSE +0 -0
  25. {processforge-0.3.26 → processforge-0.4.1}/MANIFEST.in +0 -0
  26. {processforge-0.3.26 → processforge-0.4.1}/README.md +0 -0
  27. {processforge-0.3.26 → processforge-0.4.1}/flowsheets/closed-loop-chain.json +0 -0
  28. {processforge-0.3.26 → processforge-0.4.1}/flowsheets/coupled/msre_coupled.json +0 -0
  29. {processforge-0.3.26 → processforge-0.4.1}/flowsheets/custom/crimson/MUWS_HB.json +0 -0
  30. {processforge-0.3.26 → processforge-0.4.1}/flowsheets/custom/simple_hyrdaulic_scheme.json +0 -0
  31. {processforge-0.3.26 → processforge-0.4.1}/flowsheets/festim/hydrogen_transport_1d.json +0 -0
  32. {processforge-0.3.26 → processforge-0.4.1}/flowsheets/festim/tds_tungsten.json +0 -0
  33. {processforge-0.3.26 → processforge-0.4.1}/flowsheets/hydraulic-chain.json +0 -0
  34. {processforge-0.3.26 → processforge-0.4.1}/flowsheets/openmc/msre_eigenvalue.json +0 -0
  35. {processforge-0.3.26 → processforge-0.4.1}/setup.cfg +0 -0
  36. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/__main__.py +0 -0
  37. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/_schema.py +0 -0
  38. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/analysis/__init__.py +0 -0
  39. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/analysis/dof.py +0 -0
  40. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/api/__init__.py +0 -0
  41. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/api/serve.py +0 -0
  42. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/cli/apply.py +0 -0
  43. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/cli/common.py +0 -0
  44. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/cli/diagram.py +0 -0
  45. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/cli/display.py +0 -0
  46. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/cli/export_modelica.py +0 -0
  47. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/cli/init.py +0 -0
  48. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/cli/persist.py +0 -0
  49. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/cli/plan.py +0 -0
  50. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/cli/run.py +0 -0
  51. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/cli/validate.py +0 -0
  52. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/compose.py +0 -0
  53. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/coupling.py +0 -0
  54. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/__init__.py +0 -0
  55. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/backends/__init__.py +0 -0
  56. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/backends/base.py +0 -0
  57. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/backends/casadi_backend.py +0 -0
  58. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/backends/pyomo_backend.py +0 -0
  59. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/backends/scipy_backend.py +0 -0
  60. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/flowsheet.py +0 -0
  61. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/jacobian.py +0 -0
  62. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/mixin.py +0 -0
  63. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/solver.py +0 -0
  64. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/stream_var.py +0 -0
  65. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/units/__init__.py +0 -0
  66. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/units/flash_eo.py +0 -0
  67. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/units/heater_eo.py +0 -0
  68. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/units/pipes_eo.py +0 -0
  69. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/units/pump_eo.py +0 -0
  70. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/units/strainer_eo.py +0 -0
  71. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/eo/units/valve_eo.py +0 -0
  72. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/flowsheet.py +0 -0
  73. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/fmu/__init__.py +0 -0
  74. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/lock.py +0 -0
  75. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/modelica/__init__.py +0 -0
  76. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/modelica/mo_writer.py +0 -0
  77. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/modelica/omc_runner.py +0 -0
  78. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/modelica/transpiler.py +0 -0
  79. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/modelica/unit_equations.py +0 -0
  80. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/output_collector.py +0 -0
  81. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/pcl/__init__.py +0 -0
  82. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/pcl/compiler.py +0 -0
  83. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/pcl/namespace.py +0 -0
  84. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/persistence/__init__.py +0 -0
  85. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/persistence/archive.py +0 -0
  86. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/persistence/artifact_store.py +0 -0
  87. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/provenance.py +0 -0
  88. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/__init__.py +0 -0
  89. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/base.py +0 -0
  90. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/base_jacobian_mixin.py +0 -0
  91. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/cantera_jacobian.py +0 -0
  92. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/cantera_provider.py +0 -0
  93. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/container_client.py +0 -0
  94. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/coolprop_provider.py +0 -0
  95. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/errors.py +0 -0
  96. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/festim_provider.py +0 -0
  97. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/jacobian_contributor.py +0 -0
  98. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/manager.py +0 -0
  99. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/modelica_jacobian.py +0 -0
  100. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/modelica_provider.py +0 -0
  101. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/openmc_provider.py +0 -0
  102. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/reference_state_registry.py +0 -0
  103. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/providers/registry.py +0 -0
  104. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/quantity.py +0 -0
  105. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/result_schema.py +0 -0
  106. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/schemas/__init__.py +0 -0
  107. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/schemas/festim/festim_model.py +0 -0
  108. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/schemas/flowsheet_schema.json +0 -0
  109. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/schemas/openmc/openmc_model.py +0 -0
  110. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/simulate.py +0 -0
  111. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/solver.py +0 -0
  112. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/state.py +0 -0
  113. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/thermo.py +0 -0
  114. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/types.py +0 -0
  115. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/__init__.py +0 -0
  116. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/base.py +0 -0
  117. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/cstr.py +0 -0
  118. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/flash.py +0 -0
  119. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/heater.py +0 -0
  120. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/pfr.py +0 -0
  121. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/pipes.py +0 -0
  122. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/pump.py +0 -0
  123. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/registry.py +0 -0
  124. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/solver_unit.py +0 -0
  125. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/strainer.py +0 -0
  126. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/tank.py +0 -0
  127. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/units/valve.py +0 -0
  128. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/utils/__init__.py +0 -0
  129. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/utils/flowsheet_diagram.py +0 -0
  130. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/utils/mermaid_diagram.py +0 -0
  131. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/utils/s3_upload.py +0 -0
  132. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/utils/topology.py +0 -0
  133. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/utils/unit_consistency.py +0 -0
  134. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/utils/validate_flowsheet.py +0 -0
  135. {processforge-0.3.26 → processforge-0.4.1}/src/processforge/utils/validation.py +0 -0
  136. {processforge-0.3.26 → processforge-0.4.1}/src/processforge.egg-info/dependency_links.txt +0 -0
  137. {processforge-0.3.26 → processforge-0.4.1}/src/processforge.egg-info/entry_points.txt +0 -0
  138. {processforge-0.3.26 → processforge-0.4.1}/src/processforge.egg-info/top_level.txt +0 -0
  139. {processforge-0.3.26 → processforge-0.4.1}/tests/test_container_client.py +0 -0
  140. {processforge-0.3.26 → processforge-0.4.1}/tests/test_coupling.py +0 -0
  141. {processforge-0.3.26 → processforge-0.4.1}/tests/test_display.py +0 -0
  142. {processforge-0.3.26 → processforge-0.4.1}/tests/test_festim_provider.py +0 -0
  143. {processforge-0.3.26 → processforge-0.4.1}/tests/test_festim_schema.py +0 -0
  144. {processforge-0.3.26 → processforge-0.4.1}/tests/test_init.py +0 -0
  145. {processforge-0.3.26 → processforge-0.4.1}/tests/test_openmc_provider.py +0 -0
  146. {processforge-0.3.26 → processforge-0.4.1}/tests/test_persist.py +0 -0
  147. {processforge-0.3.26 → processforge-0.4.1}/tests/test_provider_errors.py +0 -0
  148. {processforge-0.3.26 → processforge-0.4.1}/tests/test_providers.py +0 -0
  149. {processforge-0.3.26 → processforge-0.4.1}/tests/test_result_zarr.py +0 -0
  150. {processforge-0.3.26 → processforge-0.4.1}/tests/test_s3_upload.py +0 -0
  151. {processforge-0.3.26 → processforge-0.4.1}/tests/test_state.py +0 -0
  152. {processforge-0.3.26 → processforge-0.4.1}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processforge
3
- Version: 0.3.26
3
+ Version: 0.4.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
@@ -43,9 +43,13 @@ Requires-Dist: pyomo>=6.7; extra == "eo-casadi"
43
43
  Requires-Dist: casadi>=3.6; extra == "eo-casadi"
44
44
  Provides-Extra: fmu
45
45
  Requires-Dist: pythonfmu>=0.6; extra == "fmu"
46
+ Requires-Dist: fmpy>=0.3; extra == "fmu"
46
47
  Provides-Extra: modelica
47
48
  Requires-Dist: OMPython>=4.0.0; extra == "modelica"
48
49
  Requires-Dist: fmpy>=0.3; extra == "modelica"
50
+ Provides-Extra: pathsim
51
+ Requires-Dist: pathsim>=0.9; extra == "pathsim"
52
+ Requires-Dist: fmpy>=0.3; extra == "pathsim"
49
53
  Provides-Extra: cantera
50
54
  Requires-Dist: cantera>=3.0; extra == "cantera"
51
55
  Provides-Extra: coolprop
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "processforge"
7
- version = "0.3.26"
7
+ version = "0.4.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"
@@ -55,11 +55,16 @@ eo-casadi = [
55
55
  ]
56
56
  fmu = [
57
57
  "pythonfmu>=0.6",
58
+ "fmpy>=0.3",
58
59
  ]
59
60
  modelica = [
60
61
  "OMPython>=4.0.0",
61
62
  "fmpy>=0.3",
62
63
  ]
64
+ pathsim = [
65
+ "pathsim>=0.9",
66
+ "fmpy>=0.3",
67
+ ]
63
68
  cantera = [
64
69
  "cantera>=3.0",
65
70
  ]
@@ -30,7 +30,7 @@ from .units.flash import Flash
30
30
  from .units.heater import Heater
31
31
  from .eo import EOFlowsheet, EOSolver
32
32
 
33
- __version__ = "0.3.26"
33
+ __version__ = "0.4.1"
34
34
 
35
35
  __all__ = [
36
36
  "Flowsheet",
@@ -32,7 +32,7 @@ def register_commands(app: typer.Typer) -> None:
32
32
  )(apply)
33
33
  app.command(
34
34
  "runs",
35
- help="List runs for a flowsheet, or show one run's full manifest (pf runs <flowsheet> [<run_id>])",
35
+ help="List runs for a flowsheet, or inspect one run's Zarr result summary (pf runs <flowsheet> [<run_id>|latest] [--schema])",
36
36
  )(runs)
37
37
  app.command(
38
38
  "plan",
@@ -0,0 +1,88 @@
1
+ """``pf export-fmu`` — export flowsheet as FMI 2.0 co-simulation FMU."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Literal
7
+
8
+ import typer
9
+ from loguru import logger
10
+
11
+ from .common import require_existing_file
12
+
13
+
14
+ def export_fmu(
15
+ flowsheet: str = typer.Argument(help="Path to the flowsheet JSON file"),
16
+ output_dir: str = typer.Option(
17
+ "outputs",
18
+ "--output-dir",
19
+ "-o",
20
+ help="Directory for the output FMU (default: outputs/)",
21
+ ),
22
+ backend: Literal["scipy", "pyomo", "casadi"] = typer.Option(
23
+ "scipy",
24
+ "--backend",
25
+ help="EO solver backend for steady-state mode (default: scipy)",
26
+ ),
27
+ pathsim: bool = typer.Option(
28
+ False,
29
+ "--pathsim",
30
+ help="Also emit the fmu_interface.json manifest and a PathSim wiring example",
31
+ ),
32
+ ) -> None:
33
+ """Export a flowsheet as an FMI 2.0 co-simulation FMU."""
34
+ require_existing_file(flowsheet)
35
+
36
+ from ..fmu import build_fmu # local import — pythonfmu is optional
37
+
38
+ output_dir = output_dir or "outputs"
39
+ backend = backend or "scipy"
40
+
41
+ try:
42
+ fmu_path = build_fmu(
43
+ flowsheet,
44
+ output_dir=output_dir,
45
+ backend=backend,
46
+ write_manifest=pathsim,
47
+ )
48
+ logger.info(f"FMU written to: {fmu_path}")
49
+ if pathsim:
50
+ base = os.path.splitext(os.path.basename(fmu_path))[0]
51
+ manifest_path = os.path.join(output_dir, f"{base}_fmu_interface.json")
52
+ example_path = os.path.join(output_dir, f"{base}_pathsim.py")
53
+ _write_pathsim_example(example_path, fmu_path, manifest_path)
54
+ logger.info(f"Manifest written to: {manifest_path}")
55
+ logger.info(f"PathSim example written to: {example_path}")
56
+ except Exception as e:
57
+ logger.error(f"FMU export failed: {type(e).__name__}: {e}")
58
+ logger.debug("FMU export traceback:", exc_info=True)
59
+ raise SystemExit(1)
60
+
61
+
62
+ def _write_pathsim_example(example_path: str, fmu_path: str, manifest_path: str) -> None:
63
+ """Write a minimal PathSim wiring example next to the exported FMU."""
64
+ content = f'''"""Auto-generated PathSim wiring example."""
65
+ import pathsim as ps
66
+ from processforge.pathsim import ProcessForgeFMU
67
+
68
+ plant = ProcessForgeFMU(
69
+ fmu_path={fmu_path!r},
70
+ manifest_path={manifest_path!r},
71
+ )
72
+
73
+ # Inspect available ports:
74
+ # print(plant.inputs)
75
+ # print(plant.outputs)
76
+
77
+ # Example: drive an input with a constant setpoint and read an output.
78
+ setpoint = ps.Constant(val=1.0)
79
+ sim = ps.Simulation(
80
+ blocks=[setpoint, plant],
81
+ connections=[
82
+ # ps.Connection(setpoint, plant["<unit>.<input>"]),
83
+ ],
84
+ )
85
+ sim.run(time_span=(0, 10))
86
+ '''
87
+ with open(example_path, "w", encoding="utf-8") as f:
88
+ f.write(content)
@@ -0,0 +1,201 @@
1
+ """``pf runs`` — list runs and inspect their Zarr result summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+
8
+ import arrow
9
+ import typer
10
+ from loguru import logger
11
+
12
+ from .common import flowsheet_basename, output_root
13
+ from ..persistence.archive import ProcessStateArchive
14
+ from ..result import _fmt_scientific, _one_line_summary, summarize_zarr_store
15
+
16
+
17
+ def _humanize_timestamp(ts: str) -> str:
18
+ """Render a stored timestamp as a human-readable relative string.
19
+
20
+ Falls back to the raw string for values that aren't parseable ISO-8601
21
+ (e.g. ``20260101T000000Z`` used in some test fixtures).
22
+ """
23
+ try:
24
+ return arrow.get(ts).humanize()
25
+ except (arrow.parser.ParserError, ValueError, TypeError):
26
+ return ts
27
+
28
+
29
+ def _zarr_path(archive_path: str, run_id: str) -> str:
30
+ """Return the per-run Zarr store path."""
31
+ return os.path.join(archive_path, "results", run_id, "results.zarr")
32
+
33
+
34
+ def _artifact_status(artifact: dict) -> str:
35
+ """Return a short presence marker for an artifact dict."""
36
+ local_path = artifact.get("local_path")
37
+ remote_uris = artifact.get("remote_uris") or []
38
+ if local_path and os.path.exists(local_path):
39
+ return "local✓"
40
+ if remote_uris:
41
+ return "remote-only"
42
+ return "missing"
43
+
44
+
45
+ def _format_run_details(manifest, summary: dict) -> str:
46
+ """Format a detailed, human-readable result summary for a single run."""
47
+ lines = [
48
+ f"Run: {manifest.run_id}",
49
+ f"Timestamp: {manifest.timestamp}",
50
+ f"Mode: {summary.get('mode', manifest.mode)}",
51
+ "",
52
+ "Results:",
53
+ "────────",
54
+ ]
55
+
56
+ engine_outputs = summary.get("engine_outputs", {})
57
+ if engine_outputs:
58
+ for name, eo in engine_outputs.items():
59
+ engine = eo.get("engine", "")
60
+ sim_type = eo.get("sim_type", "")
61
+ status = eo.get("status", "")
62
+ lines.append(f"{name} ({engine} / {sim_type}) [{status}]")
63
+ for fname, fdata in eo.get("fields", {}).items():
64
+ val = _fmt_scientific(fdata.get("value"))
65
+ unit = fdata.get("unit", "")
66
+ std = fdata.get("std_dev")
67
+ if std is not None:
68
+ lines.append(f" {fname}: {val} ± {_fmt_scientific(std)} {unit}".strip())
69
+ else:
70
+ lines.append(f" {fname}: {val} {unit}".strip())
71
+ if eo.get("attrs") and not eo.get("fields"):
72
+ for k, v in eo["attrs"].items():
73
+ if not k.startswith("_") and k not in ("engine", "sim_type", "status"):
74
+ lines.append(f" {k}: {_fmt_scientific(v)}")
75
+ lines.append("")
76
+ else:
77
+ lines.append(" (no solver/engine outputs)")
78
+ lines.append("")
79
+
80
+ streams = summary.get("streams", {})
81
+ if streams:
82
+ lines.append("Streams:")
83
+ all_vars: set[str] = set()
84
+ for sdata in streams.values():
85
+ all_vars.update(sdata.get("variables", []))
86
+ all_vars = sorted(all_vars)
87
+
88
+ header_parts = ["stream".ljust(20)] + [v.rjust(14) for v in all_vars]
89
+ lines.append(" " + " ".join(header_parts))
90
+ lines.append(" " + "-" * len(" ".join(header_parts)))
91
+ for sname in sorted(streams):
92
+ sdata = streams[sname]
93
+ fields = sdata.get("fields", {})
94
+ row = [sname.ljust(20)]
95
+ for var in all_vars:
96
+ fdata = fields.get(var, {})
97
+ val = fdata.get("value")
98
+ row.append(_fmt_scientific(val).rjust(14))
99
+ lines.append(" " + " ".join(row))
100
+ lines.append("")
101
+
102
+ artifacts = summary.get("artifacts", [])
103
+ if artifacts:
104
+ lines.append("Artifacts:")
105
+ for art in artifacts:
106
+ name = art.get("name", "unknown")
107
+ kind = art.get("kind", "")
108
+ status = _artifact_status(art)
109
+ loc = art.get("local_path") or (art.get("remote_uris") or ["no location"])[0]
110
+ lines.append(f" [{kind}] {name}: {status} ({loc})")
111
+ else:
112
+ lines.append("Artifacts: none")
113
+
114
+ zarr_path = summary.get("_zarr_path")
115
+ if zarr_path:
116
+ lines.append("")
117
+ lines.append(f"Zarr store: {zarr_path}")
118
+
119
+ return "\n".join(lines)
120
+
121
+
122
+ def runs(
123
+ flowsheet: str = typer.Argument(..., help="Path to the flowsheet JSON"),
124
+ run_id: str = typer.Argument(
125
+ None,
126
+ help="Show this run's result summary. Use 'latest' for the most recent run.",
127
+ ),
128
+ schema: bool = typer.Option(
129
+ False,
130
+ "--schema",
131
+ help="Print the result schema JSON instead of the summary.",
132
+ ),
133
+ ):
134
+ """List runs for a flowsheet, or inspect one run's Zarr result summary."""
135
+ base = flowsheet_basename(flowsheet)
136
+ archive_path = os.path.join(output_root(), f"{base}.pfarchive")
137
+
138
+ if not os.path.isdir(archive_path):
139
+ logger.error(
140
+ f"No archive found for '{flowsheet}' at {archive_path}. "
141
+ "Run 'pf run' first."
142
+ )
143
+ raise typer.Exit(code=1)
144
+
145
+ archive = ProcessStateArchive(archive_path)
146
+ latest = archive.latest_run()
147
+ latest_id = latest.run_id if latest is not None else None
148
+
149
+ if run_id is not None:
150
+ resolved_id = latest_id if run_id == "latest" and latest_id else run_id
151
+ if resolved_id != run_id and latest_id is None:
152
+ logger.error(f"No latest run found in {archive_path}.")
153
+ raise typer.Exit(code=1)
154
+
155
+ if resolved_id not in archive.list_runs():
156
+ logger.error(f"Run '{resolved_id}' not found in {archive_path}/runs.")
157
+ raise typer.Exit(code=1)
158
+
159
+ zarr_path = _zarr_path(archive_path, resolved_id)
160
+
161
+ if schema:
162
+ schema_path = zarr_path + ".schema.json"
163
+ if not os.path.isfile(schema_path):
164
+ logger.error(f"No schema found for run '{resolved_id}'.")
165
+ raise typer.Exit(code=1)
166
+ with open(schema_path, "r", encoding="utf-8") as f:
167
+ typer.echo(f.read())
168
+ return
169
+
170
+ manifest = archive.load_run(resolved_id)
171
+ summary = summarize_zarr_store(zarr_path)
172
+ summary["_zarr_path"] = zarr_path
173
+ typer.echo(_format_run_details(manifest, summary))
174
+ return
175
+
176
+ # List all runs with a one-line summary.
177
+ run_ids = archive.list_runs()
178
+ if not run_ids:
179
+ typer.echo(f"No runs found for '{flowsheet}'.")
180
+ return
181
+
182
+ header = f"{'RUN ID':<28} {'TIMESTAMP':<22} {'LATEST':<7} {'SUMMARY':<30}"
183
+ typer.echo(header)
184
+ typer.echo("-" * len(header))
185
+ for rid in run_ids:
186
+ manifest = archive.load_run(rid)
187
+ if manifest is None:
188
+ continue
189
+ marker = "*" if rid == latest_id else " "
190
+ zarr_path = _zarr_path(archive_path, rid)
191
+ summary = summarize_zarr_store(zarr_path)
192
+ one_liner = _one_line_summary(summary)
193
+ typer.echo(
194
+ f"{rid:<28} {_humanize_timestamp(manifest.timestamp):<22} {marker:<7} {one_liner:<30}"
195
+ )
196
+ typer.echo("")
197
+ typer.echo(
198
+ "'*' marks the latest run. "
199
+ "'pf runs <flowsheet> <run_id>' shows the result summary. "
200
+ "Use 'latest' as the run id for the most recent run."
201
+ )
@@ -3,12 +3,45 @@ from __future__ import annotations
3
3
 
4
4
  import re
5
5
 
6
+ from .interface import SolverUnitPort
7
+
6
8
 
7
9
  def _sanitize_name(s: str) -> str:
8
10
  """Replace characters invalid in Python identifiers with underscores."""
9
11
  return re.sub(r"[^a-zA-Z0-9_]", "_", s)
10
12
 
11
13
 
14
+ def solverunit_port_to_spec(port: SolverUnitPort) -> dict:
15
+ """Convert a :class:`SolverUnitPort` to the internal spec dict format."""
16
+ return {
17
+ "attr_name": port.attr_name,
18
+ "initial_value": float(port.initial_value),
19
+ "causality": port.causality,
20
+ "variability": port.variability,
21
+ "description": port.description,
22
+ "unit": port.unit,
23
+ "logical_name": f"{port.unit_name}.{port.path}",
24
+ }
25
+
26
+
27
+ def ensure_unique_attr_names(specs: list[dict]) -> list[dict]:
28
+ """Deduplicate ``attr_name`` values by appending a counter.
29
+
30
+ Preserves order and returns a new list of specs.
31
+ """
32
+ seen: dict[str, int] = {}
33
+ out: list[dict] = []
34
+ for spec in specs:
35
+ name = spec["attr_name"]
36
+ count = seen.get(name, 0)
37
+ if count:
38
+ new_name = f"{name}_{count}"
39
+ spec = {**spec, "attr_name": new_name}
40
+ seen[name] = count + 1
41
+ out.append(spec)
42
+ return out
43
+
44
+
12
45
  def get_fmi_variable_specs(
13
46
  feed_streams: list[str],
14
47
  output_streams: list[str],
@@ -115,8 +148,8 @@ def get_fmi_variable_specs(
115
148
  "description": f"Parameter '{key}' of unit '{unit_name}'",
116
149
  })
117
150
 
118
- # --- Tank state outputs (dynamic mode only) ---
119
- if mode == "dynamic":
151
+ # --- Tank state outputs (dynamic / solverunit_dynamic modes only) ---
152
+ if mode in ("dynamic", "solverunit_dynamic"):
120
153
  for unit_name, unit_cfg in config["units"].items():
121
154
  if unit_cfg.get("type") != "Tank":
122
155
  continue
@@ -7,9 +7,17 @@ import shutil
7
7
  import subprocess
8
8
  import tempfile
9
9
 
10
+ import json
11
+
10
12
  from ..utils.validate_flowsheet import validate_flowsheet
11
13
  from ..utils.topology import TOPOLOGY_KEYS, get_outlets
12
- from ._fmi_vars import _sanitize_name
14
+ from ._fmi_vars import (
15
+ _sanitize_name,
16
+ ensure_unique_attr_names,
17
+ get_fmi_variable_specs,
18
+ solverunit_port_to_spec,
19
+ )
20
+ from .interface import build_solverunit_interface, build_fmu_interface_manifest
13
21
  from .slave_template import render_slave_source
14
22
 
15
23
 
@@ -17,6 +25,7 @@ def build_fmu(
17
25
  config_path: str,
18
26
  output_dir: str = "outputs",
19
27
  backend: str = "scipy",
28
+ write_manifest: bool = True,
20
29
  ) -> str:
21
30
  """Build an FMI 2.0 co-simulation FMU from a Processforge flowsheet JSON.
22
31
 
@@ -25,6 +34,8 @@ def build_fmu(
25
34
  output_dir: Directory where the ``.fmu`` file will be written.
26
35
  backend: EO solver backend for steady-state mode
27
36
  (``"scipy"``, ``"pyomo"``, or ``"casadi"``).
37
+ write_manifest: If ``True`` (default), write ``fmu_interface.json`` next
38
+ to the ``.fmu`` file.
28
39
 
29
40
  Returns:
30
41
  Absolute path to the generated ``.fmu`` file.
@@ -42,6 +53,26 @@ def build_fmu(
42
53
  interface = _analyze_config(config)
43
54
  slave_class_name = _get_slave_class_name(config, config_path)
44
55
 
56
+ solverunit_ports: dict = {}
57
+ if interface.get("has_solverunit"):
58
+ solverunit_ports = build_solverunit_interface(config)
59
+ interface["solverunit_ports"] = solverunit_ports
60
+
61
+ stream_specs = get_fmi_variable_specs(
62
+ interface["feed_streams"],
63
+ interface["output_streams"],
64
+ interface["components"],
65
+ interface["unit_params"],
66
+ config,
67
+ interface["mode"],
68
+ )
69
+ solverunit_specs = [
70
+ solverunit_port_to_spec(port)
71
+ for ports in solverunit_ports.values()
72
+ for port in ports
73
+ ]
74
+ specs = ensure_unique_attr_names(stream_specs + solverunit_specs)
75
+
45
76
  os.makedirs(output_dir, exist_ok=True)
46
77
 
47
78
  with tempfile.TemporaryDirectory() as staging_dir:
@@ -58,6 +89,8 @@ def build_fmu(
58
89
  output_streams=interface["output_streams"],
59
90
  components=interface["components"],
60
91
  unit_params=interface["unit_params"],
92
+ solverunit_ports=solverunit_ports,
93
+ specs=specs,
61
94
  config=config,
62
95
  )
63
96
 
@@ -86,6 +119,19 @@ def build_fmu(
86
119
  fmu_path = os.path.abspath(
87
120
  os.path.join(output_dir, f"{slave_class_name}.fmu")
88
121
  )
122
+
123
+ if write_manifest:
124
+ parameter_specs = [spec for spec in specs if spec["causality"] == "parameter"]
125
+ non_param_specs = [spec for spec in specs if spec["causality"] != "parameter"]
126
+ manifest = build_fmu_interface_manifest(
127
+ config, non_param_specs, interface.get("solverunit_ports", {}), parameter_specs
128
+ )
129
+ manifest_path = os.path.join(
130
+ output_dir, f"{slave_class_name}_fmu_interface.json"
131
+ )
132
+ with open(manifest_path, "w", encoding="utf-8") as f:
133
+ json.dump(manifest, f, indent=2)
134
+
89
135
  return fmu_path
90
136
 
91
137
 
@@ -140,12 +186,21 @@ def _analyze_config(config: dict) -> dict:
140
186
  if params:
141
187
  unit_params[unit_name] = params
142
188
 
143
- # Simulation mode — force "dynamic" if any Tank is present
189
+ # Simulation mode
144
190
  mode = config.get("simulation", {}).get("mode", "steady")
145
191
  has_tank = any(
146
192
  cfg.get("type") == "Tank" for cfg in config["units"].values()
147
193
  )
148
- if has_tank:
194
+ has_solverunit = any(
195
+ cfg.get("type") == "SolverUnit" for cfg in config["units"].values()
196
+ )
197
+ if has_solverunit and has_tank:
198
+ # Both dynamic Tank(s) and SolverUnit(s) require a mixed slave.
199
+ mode = "solverunit_dynamic"
200
+ elif has_solverunit:
201
+ # SolverUnit flowsheets are driven by the Flowsheet-based slave.
202
+ mode = "solverunit"
203
+ elif has_tank:
149
204
  mode = "dynamic"
150
205
 
151
206
  return {
@@ -154,6 +209,8 @@ def _analyze_config(config: dict) -> dict:
154
209
  "components": components,
155
210
  "unit_params": unit_params,
156
211
  "mode": mode,
212
+ "has_solverunit": has_solverunit,
213
+ "has_tank": has_tank,
157
214
  }
158
215
 
159
216
  def _get_slave_class_name(config: dict, config_path: str) -> str: