mfcblend 0.1.0__py3-none-any.whl

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.
mfcblend/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """MFCBlend public Python API."""
2
+
3
+ from .core import (
4
+ Cylinder,
5
+ FeedResult,
6
+ FeedSystem,
7
+ InputError,
8
+ MFCConstraints,
9
+ SolutionStatus,
10
+ StandardConditions,
11
+ convert_reference_flow,
12
+ forward_mix,
13
+ ghsv,
14
+ inverse_mix,
15
+ molar_flow,
16
+ partial_pressures,
17
+ total_molar_flow,
18
+ )
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "Cylinder",
24
+ "FeedResult",
25
+ "FeedSystem",
26
+ "InputError",
27
+ "MFCConstraints",
28
+ "SolutionStatus",
29
+ "StandardConditions",
30
+ "convert_reference_flow",
31
+ "forward_mix",
32
+ "ghsv",
33
+ "inverse_mix",
34
+ "molar_flow",
35
+ "partial_pressures",
36
+ "total_molar_flow",
37
+ ]
mfcblend/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Application orchestration."""
2
+
3
+ from .service import RunRequest, execute, execute_and_export
4
+
5
+ __all__ = ["RunRequest", "execute", "execute_and_export"]
@@ -0,0 +1,38 @@
1
+ """Application services shared by the CLI and GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from mfcblend.core import FeedResult, forward_mix, inverse_mix
9
+ from mfcblend.io import export_result, load_setpoints, load_system, load_target
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class RunRequest:
14
+ system_path: Path
15
+ values_path: Path
16
+ mode: str
17
+ allow_approximate: bool = False
18
+
19
+
20
+ def execute(request: RunRequest) -> FeedResult:
21
+ system = load_system(request.system_path)
22
+ if request.mode == "forward":
23
+ return forward_mix(system, load_setpoints(request.values_path))
24
+ if request.mode == "inverse":
25
+ composition, total_flow = load_target(request.values_path)
26
+ return inverse_mix(
27
+ system,
28
+ composition,
29
+ total_flow,
30
+ allow_approximate=request.allow_approximate,
31
+ )
32
+ raise ValueError("mode must be 'forward' or 'inverse'.")
33
+
34
+
35
+ def execute_and_export(request: RunRequest, destination: str | Path) -> FeedResult:
36
+ result = execute(request)
37
+ export_result(result, destination)
38
+ return result
mfcblend/cli.py ADDED
@@ -0,0 +1,79 @@
1
+ """Headless command-line adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ from mfcblend.application import RunRequest, execute
12
+ from mfcblend.core import InputError
13
+ from mfcblend.io import export_result, load_system, result_dict
14
+
15
+
16
+ def _parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="mfcblend",
19
+ description="Plan catalytic-reactor gas feeds from cylinders and constrained MFCs.",
20
+ )
21
+ parser.add_argument("--version", action="version", version="%(prog)s 0.1.0")
22
+ subparsers = parser.add_subparsers(dest="command", required=True)
23
+
24
+ validate = subparsers.add_parser("validate", help="validate a feed-system JSON file")
25
+ validate.add_argument("system", type=Path)
26
+
27
+ for mode in ("forward", "inverse"):
28
+ command = subparsers.add_parser(mode, help=f"run a {mode} gas-feed calculation")
29
+ command.add_argument(
30
+ "system", type=Path, help="cylinders, MFC limits, and reference conditions"
31
+ )
32
+ command.add_argument(
33
+ "values", type=Path, help="setpoints JSON (forward) or target JSON (inverse)"
34
+ )
35
+ command.add_argument("--output", type=Path, help="write a .json or .csv result")
36
+ if mode == "inverse":
37
+ command.add_argument(
38
+ "--allow-approximate",
39
+ action="store_true",
40
+ help="return a clearly labelled approximate plan when no exact plan exists",
41
+ )
42
+ return parser
43
+
44
+
45
+ def main(argv: Sequence[str] | None = None) -> int:
46
+ args = _parser().parse_args(argv)
47
+ try:
48
+ if args.command == "validate":
49
+ system = load_system(args.system)
50
+ print(
51
+ json.dumps(
52
+ {
53
+ "status": "valid",
54
+ "cylinders": len(system.cylinders),
55
+ "species": system.species,
56
+ "flow_unit": system.flow_unit,
57
+ },
58
+ indent=2,
59
+ )
60
+ )
61
+ return 0
62
+ request = RunRequest(
63
+ system_path=args.system,
64
+ values_path=args.values,
65
+ mode=args.command,
66
+ allow_approximate=getattr(args, "allow_approximate", False),
67
+ )
68
+ result = execute(request)
69
+ if args.output:
70
+ export_result(result, args.output)
71
+ print(json.dumps(result_dict(result), indent=2, sort_keys=True))
72
+ return 2 if result.status.value == "infeasible" else 0
73
+ except (InputError, OSError, ValueError) as exc:
74
+ print(f"mfcblend: error: {exc}", file=sys.stderr)
75
+ return 2
76
+
77
+
78
+ if __name__ == "__main__":
79
+ raise SystemExit(main())
@@ -0,0 +1,31 @@
1
+ """Scientific core: no GUI or plotting imports."""
2
+
3
+ from .derived import ghsv, partial_pressures, total_molar_flow
4
+ from .mixing import forward_mix, inverse_mix
5
+ from .models import (
6
+ Cylinder,
7
+ FeedResult,
8
+ FeedSystem,
9
+ InputError,
10
+ MFCConstraints,
11
+ SolutionStatus,
12
+ StandardConditions,
13
+ )
14
+ from .standard import convert_reference_flow, molar_flow
15
+
16
+ __all__ = [
17
+ "Cylinder",
18
+ "FeedResult",
19
+ "FeedSystem",
20
+ "InputError",
21
+ "MFCConstraints",
22
+ "SolutionStatus",
23
+ "StandardConditions",
24
+ "convert_reference_flow",
25
+ "forward_mix",
26
+ "ghsv",
27
+ "inverse_mix",
28
+ "molar_flow",
29
+ "partial_pressures",
30
+ "total_molar_flow",
31
+ ]
@@ -0,0 +1,42 @@
1
+ """Derived reactor-feed quantities with explicit bases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from .models import FeedResult, InputError
8
+ from .standard import molar_flow
9
+
10
+
11
+ def total_molar_flow(result: FeedResult) -> float:
12
+ """Return ideal-gas molar flow in mol/s at the stated reference conditions."""
13
+
14
+ return molar_flow(result.total_flow, result.flow_unit, result.standard_conditions)
15
+
16
+
17
+ def partial_pressures(
18
+ composition: Mapping[str, float], total_pressure_pa: float
19
+ ) -> dict[str, float]:
20
+ """Return ideal-mixture partial pressures in Pa using ``p_i = y_i P``."""
21
+
22
+ if total_pressure_pa <= 0:
23
+ raise InputError("Total pressure must be greater than zero Pa absolute.")
24
+ return {species: fraction * total_pressure_pa for species, fraction in composition.items()}
25
+
26
+
27
+ def ghsv(result: FeedResult, catalyst_bed_volume_ml: float) -> float:
28
+ """Return GHSV in h^-1 from reference volumetric flow / catalyst-bed volume.
29
+
30
+ This uses the same standard/reference conditions attached to ``result``.
31
+ It does not convert the feed to reactor-temperature actual volume.
32
+ """
33
+
34
+ if catalyst_bed_volume_ml <= 0:
35
+ raise InputError("Catalyst-bed volume must be greater than zero mL.")
36
+ if result.flow_unit in {"sccm", "nml/min"}:
37
+ flow_ml_min = result.total_flow
38
+ elif result.flow_unit == "slm":
39
+ flow_ml_min = result.total_flow * 1000.0
40
+ else: # defensive: FeedSystem already validates this
41
+ raise InputError(f"Unsupported flow unit {result.flow_unit!r}.")
42
+ return flow_ml_min * 60.0 / catalyst_bed_volume_ml
@@ -0,0 +1,259 @@
1
+ """Forward and constrained inverse linear gas-mixing calculations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from itertools import combinations
7
+ from math import isfinite
8
+
9
+ import numpy as np
10
+ from scipy.optimize import lsq_linear
11
+
12
+ from .models import FeedResult, FeedSystem, InputError, SolutionStatus
13
+
14
+
15
+ def _ordered_setpoints(system: FeedSystem, setpoints: Mapping[str, float]) -> np.ndarray:
16
+ unknown = set(setpoints) - {cylinder.name for cylinder in system.cylinders}
17
+ if unknown:
18
+ raise InputError(f"Unknown cylinder setpoints: {', '.join(sorted(unknown))}.")
19
+ ordered: list[float] = []
20
+ for cylinder in system.cylinders:
21
+ value = float(setpoints.get(cylinder.name, 0.0))
22
+ if not isfinite(value):
23
+ raise InputError(f"Setpoint for {cylinder.name!r} must be finite.")
24
+ if not cylinder.mfc.accepts(value):
25
+ raise InputError(
26
+ f"Setpoint {value:g} for {cylinder.name!r} is neither off nor within "
27
+ f"[{cylinder.mfc.effective_minimum:g}, {cylinder.mfc.maximum:g}] "
28
+ f"{system.flow_unit}."
29
+ )
30
+ ordered.append(0.0 if abs(value) <= 1e-12 else value)
31
+ return np.asarray(ordered, dtype=float)
32
+
33
+
34
+ def _forward_from_vector(system: FeedSystem, flows: np.ndarray) -> FeedResult:
35
+ total = float(np.sum(flows))
36
+ if total <= 0:
37
+ raise InputError("Total flow must be greater than zero.")
38
+ component_flows = {
39
+ species: float(
40
+ sum(
41
+ flow * cylinder.composition.get(species, 0.0)
42
+ for flow, cylinder in zip(flows, system.cylinders, strict=True)
43
+ )
44
+ )
45
+ for species in system.species
46
+ }
47
+ composition = {
48
+ species: component_flow / total for species, component_flow in component_flows.items()
49
+ }
50
+ ratios = {
51
+ f"{numerator}/{denominator}": (
52
+ None
53
+ if composition[denominator] == 0
54
+ else composition[numerator] / composition[denominator]
55
+ )
56
+ for numerator, denominator in system.reported_ratios
57
+ }
58
+ diluent_fraction = (
59
+ sum(composition[species] for species in system.diluents) if system.diluents else None
60
+ )
61
+ return FeedResult(
62
+ status=SolutionStatus.EXACT,
63
+ setpoints={
64
+ cylinder.name: float(flow)
65
+ for cylinder, flow in zip(system.cylinders, flows, strict=True)
66
+ },
67
+ total_flow=total,
68
+ composition=composition,
69
+ component_flows=component_flows,
70
+ flow_unit=system.flow_unit,
71
+ standard_conditions=system.standard_conditions,
72
+ ratios=ratios,
73
+ diluent_fraction=diluent_fraction,
74
+ messages=(
75
+ "Composition is a molar-fraction result from ideal mixing of the stated "
76
+ "cylinder compositions.",
77
+ "This plan does not assess mixture flammability, compatibility, or process safety.",
78
+ ),
79
+ )
80
+
81
+
82
+ def forward_mix(system: FeedSystem, setpoints: Mapping[str, float]) -> FeedResult:
83
+ """Calculate the mixed feed from user-supplied MFC setpoints."""
84
+
85
+ return _forward_from_vector(system, _ordered_setpoints(system, setpoints))
86
+
87
+
88
+ def _validate_target(
89
+ system: FeedSystem, target_composition: Mapping[str, float], total_flow: float
90
+ ) -> dict[str, float]:
91
+ if not isfinite(total_flow) or total_flow <= 0:
92
+ raise InputError("Target total flow must be finite and greater than zero.")
93
+ if not target_composition:
94
+ raise InputError("At least one target species fraction is required.")
95
+ target: dict[str, float] = {}
96
+ for species, value in target_composition.items():
97
+ if species not in system.species:
98
+ raise InputError(f"Target species {species!r} is absent from every cylinder.")
99
+ fraction = float(value)
100
+ if not isfinite(fraction) or fraction < 0 or fraction > 1:
101
+ raise InputError(f"Target fraction for {species!r} must be finite and within [0, 1].")
102
+ target[species] = fraction
103
+ closure = sum(target.values())
104
+ if closure > 1 + 1e-9:
105
+ raise InputError(f"Specified target fractions sum to {closure:.12g}, which exceeds 1.")
106
+ return target
107
+
108
+
109
+ def _active_subsets(count: int) -> list[tuple[int, ...]]:
110
+ if count > 16:
111
+ raise InputError(
112
+ "Inverse planning currently supports at most 16 MFCs because off/on limits "
113
+ "are enumerated exactly."
114
+ )
115
+ return [subset for size in range(1, count + 1) for subset in combinations(range(count), size)]
116
+
117
+
118
+ def inverse_mix(
119
+ system: FeedSystem,
120
+ target_composition: Mapping[str, float],
121
+ total_flow: float,
122
+ *,
123
+ allow_approximate: bool = False,
124
+ composition_tolerance: float = 1e-7,
125
+ total_flow_tolerance: float = 1e-7,
126
+ ) -> FeedResult:
127
+ """Solve constrained MFC setpoints for a requested final feed.
128
+
129
+ The model is the linear material balance ``A q = b``. Each MFC may be off,
130
+ or it must lie inside its effective operating range. Those disjoint bounds
131
+ are handled by enumerating active MFC subsets and solving each bounded
132
+ linear least-squares problem. An approximate solution is returned only when
133
+ ``allow_approximate`` is true.
134
+ """
135
+
136
+ target = _validate_target(system, target_composition, total_flow)
137
+ if composition_tolerance <= 0 or total_flow_tolerance <= 0:
138
+ raise InputError("Inverse-solver tolerances must be greater than zero.")
139
+ target_species = tuple(target)
140
+ best: tuple[float, np.ndarray, FeedResult, float, float] | None = None
141
+
142
+ for active in _active_subsets(len(system.cylinders)):
143
+ component_matrix = np.asarray(
144
+ [
145
+ [system.cylinders[index].composition.get(species, 0.0) for index in active]
146
+ for species in target_species
147
+ ],
148
+ dtype=float,
149
+ )
150
+ # Normalize all material-balance rows by target total flow. This makes
151
+ # component and total-flow residuals dimensionless and comparable.
152
+ matrix = np.vstack((component_matrix, np.ones(len(active), dtype=float))) / total_flow
153
+ rhs = np.asarray([*target.values(), 1.0], dtype=float)
154
+ lower = np.asarray(
155
+ [system.cylinders[index].mfc.effective_minimum for index in active], dtype=float
156
+ )
157
+ upper = np.asarray([system.cylinders[index].mfc.maximum for index in active], dtype=float)
158
+ fixed = np.isclose(lower, upper, rtol=1e-12, atol=1e-12)
159
+ active_solution = np.empty(len(active), dtype=float)
160
+ active_solution[fixed] = (lower[fixed] + upper[fixed]) / 2
161
+ remaining_rhs = rhs - matrix[:, fixed] @ active_solution[fixed]
162
+ if np.any(~fixed):
163
+ solved = lsq_linear(
164
+ matrix[:, ~fixed],
165
+ remaining_rhs,
166
+ bounds=(lower[~fixed], upper[~fixed]),
167
+ lsmr_tol="auto",
168
+ )
169
+ active_solution[~fixed] = solved.x
170
+ flows = np.zeros(len(system.cylinders), dtype=float)
171
+ flows[list(active)] = active_solution
172
+ try:
173
+ forward = _forward_from_vector(system, flows)
174
+ except InputError:
175
+ continue
176
+ composition_error = max(
177
+ abs(forward.composition.get(species, 0.0) - fraction)
178
+ for species, fraction in target.items()
179
+ )
180
+ flow_error = abs(forward.total_flow - total_flow)
181
+ residual = float(np.linalg.norm(matrix @ active_solution - rhs))
182
+ if best is None or residual < best[0]:
183
+ best = (residual, flows, forward, composition_error, flow_error)
184
+
185
+ if best is None:
186
+ return FeedResult(
187
+ status=SolutionStatus.INFEASIBLE,
188
+ setpoints={},
189
+ total_flow=0.0,
190
+ composition={},
191
+ component_flows={},
192
+ flow_unit=system.flow_unit,
193
+ standard_conditions=system.standard_conditions,
194
+ target_composition=target,
195
+ target_total_flow=total_flow,
196
+ messages=(
197
+ "Target composition is infeasible with the available cylinders and MFC "
198
+ "operating ranges.",
199
+ "No setpoints are returned as a successful plan.",
200
+ ),
201
+ )
202
+
203
+ _, flows, forward, composition_error, flow_error = best
204
+ is_exact = (
205
+ composition_error <= composition_tolerance
206
+ and flow_error <= total_flow_tolerance * max(1.0, total_flow)
207
+ )
208
+ if not is_exact and not allow_approximate:
209
+ return FeedResult(
210
+ status=SolutionStatus.INFEASIBLE,
211
+ setpoints={},
212
+ total_flow=forward.total_flow,
213
+ composition=forward.composition,
214
+ component_flows=forward.component_flows,
215
+ flow_unit=system.flow_unit,
216
+ standard_conditions=system.standard_conditions,
217
+ target_composition=target,
218
+ target_total_flow=total_flow,
219
+ maximum_composition_error=composition_error,
220
+ total_flow_error=flow_error,
221
+ ratios=forward.ratios,
222
+ diluent_fraction=forward.diluent_fraction,
223
+ messages=(
224
+ "Target composition is infeasible within the requested tolerances and MFC "
225
+ "operating ranges.",
226
+ "The achieved composition is diagnostic only; no setpoints are returned "
227
+ "as a successful plan.",
228
+ ),
229
+ )
230
+
231
+ status = SolutionStatus.EXACT if is_exact else SolutionStatus.APPROXIMATE
232
+ status_message = (
233
+ "An exact constrained material-balance solution was found."
234
+ if is_exact
235
+ else "Only an approximate constrained solution was found; review the reported residuals."
236
+ )
237
+ return FeedResult(
238
+ status=status,
239
+ setpoints={
240
+ cylinder.name: float(flow)
241
+ for cylinder, flow in zip(system.cylinders, flows, strict=True)
242
+ },
243
+ total_flow=forward.total_flow,
244
+ composition=forward.composition,
245
+ component_flows=forward.component_flows,
246
+ flow_unit=system.flow_unit,
247
+ standard_conditions=system.standard_conditions,
248
+ target_composition=target,
249
+ target_total_flow=total_flow,
250
+ maximum_composition_error=composition_error,
251
+ total_flow_error=flow_error,
252
+ ratios=forward.ratios,
253
+ diluent_fraction=forward.diluent_fraction,
254
+ messages=(
255
+ status_message,
256
+ "The solve uses ideal linear mixing on a molar-flow basis.",
257
+ "This plan does not assess mixture flammability, compatibility, or process safety.",
258
+ ),
259
+ )
@@ -0,0 +1,172 @@
1
+ """Validated domain models for gas-feed planning.
2
+
3
+ All compositions are molar fractions. Flow numbers are equivalent volumetric
4
+ flows at one explicitly supplied reference temperature and pressure.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from math import isfinite
13
+
14
+
15
+ class InputError(ValueError):
16
+ """Raised when an input is mathematically or physically invalid."""
17
+
18
+
19
+ def _require_finite(name: str, value: float) -> None:
20
+ if not isfinite(value):
21
+ raise InputError(f"{name} must be finite; received {value!r}.")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class StandardConditions:
26
+ """Reference conditions attached to an equivalent volumetric gas flow."""
27
+
28
+ temperature_k: float
29
+ pressure_pa: float
30
+
31
+ def __post_init__(self) -> None:
32
+ _require_finite("reference temperature", self.temperature_k)
33
+ _require_finite("reference pressure", self.pressure_pa)
34
+ if self.temperature_k <= 0:
35
+ raise InputError("Reference temperature must be greater than 0 K.")
36
+ if self.pressure_pa <= 0:
37
+ raise InputError("Reference pressure must be greater than 0 Pa absolute.")
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class MFCConstraints:
42
+ """Setpoint limits for one MFC, in the plan's common flow unit.
43
+
44
+ An MFC may be off (zero) or operate from ``effective_minimum`` through
45
+ ``maximum``. ``turndown`` is full-scale flow divided by minimum controllable
46
+ flow; the tool does not infer a vendor-specific turndown when it is omitted.
47
+ """
48
+
49
+ minimum: float
50
+ maximum: float
51
+ turndown: float | None = None
52
+
53
+ def __post_init__(self) -> None:
54
+ _require_finite("MFC minimum", self.minimum)
55
+ _require_finite("MFC maximum", self.maximum)
56
+ if self.minimum < 0:
57
+ raise InputError("MFC minimum cannot be negative.")
58
+ if self.maximum <= 0:
59
+ raise InputError("MFC maximum must be greater than zero.")
60
+ if self.minimum > self.maximum:
61
+ raise InputError("MFC minimum cannot exceed MFC maximum.")
62
+ if self.turndown is not None:
63
+ _require_finite("MFC turndown", self.turndown)
64
+ if self.turndown < 1:
65
+ raise InputError("MFC turndown must be at least 1 (full scale / minimum).")
66
+
67
+ @property
68
+ def effective_minimum(self) -> float:
69
+ turndown_minimum = 0.0 if self.turndown is None else self.maximum / self.turndown
70
+ return max(self.minimum, turndown_minimum)
71
+
72
+ def accepts(self, flow: float, *, atol: float = 1e-12) -> bool:
73
+ _require_finite("MFC setpoint", flow)
74
+ if abs(flow) <= atol:
75
+ return True
76
+ return self.effective_minimum - atol <= flow <= self.maximum + atol
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Cylinder:
81
+ """A cylinder composition and the constraints of its connected MFC."""
82
+
83
+ name: str
84
+ composition: Mapping[str, float]
85
+ mfc: MFCConstraints
86
+
87
+ def __post_init__(self) -> None:
88
+ if not self.name.strip():
89
+ raise InputError("Cylinder name cannot be empty.")
90
+ if not self.composition:
91
+ raise InputError(f"Cylinder {self.name!r} must contain at least one species.")
92
+ normalized: dict[str, float] = {}
93
+ for species, fraction in self.composition.items():
94
+ if not species.strip():
95
+ raise InputError(f"Cylinder {self.name!r} contains an empty species name.")
96
+ _require_finite(f"fraction of {species} in {self.name}", fraction)
97
+ if fraction < 0 or fraction > 1:
98
+ raise InputError(f"Fraction of {species!r} in {self.name!r} must be within [0, 1].")
99
+ normalized[species.strip()] = float(fraction)
100
+ closure = sum(normalized.values())
101
+ if abs(closure - 1.0) > 1e-9:
102
+ raise InputError(f"Cylinder {self.name!r} composition sums to {closure:.12g}, not 1.")
103
+ object.__setattr__(self, "composition", normalized)
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class FeedSystem:
108
+ """Cylinders sharing one flow unit and reference condition convention."""
109
+
110
+ cylinders: tuple[Cylinder, ...]
111
+ flow_unit: str
112
+ standard_conditions: StandardConditions
113
+ reported_ratios: tuple[tuple[str, str], ...] = ()
114
+ diluents: tuple[str, ...] = ()
115
+
116
+ def __post_init__(self) -> None:
117
+ if not self.cylinders:
118
+ raise InputError("At least one cylinder is required.")
119
+ names = [cylinder.name for cylinder in self.cylinders]
120
+ if len(set(names)) != len(names):
121
+ raise InputError("Cylinder names must be unique.")
122
+ if self.flow_unit not in {"sccm", "slm", "nml/min"}:
123
+ raise InputError("flow_unit must be one of: sccm, slm, nml/min.")
124
+ known_species = set(self.species)
125
+ for numerator, denominator in self.reported_ratios:
126
+ if numerator not in known_species or denominator not in known_species:
127
+ raise InputError(
128
+ f"Reported ratio {numerator}/{denominator} refers to an unknown species."
129
+ )
130
+ unknown_diluents = set(self.diluents) - known_species
131
+ if unknown_diluents:
132
+ raise InputError(
133
+ f"Diluents are absent from every cylinder: {', '.join(sorted(unknown_diluents))}."
134
+ )
135
+
136
+ @property
137
+ def species(self) -> tuple[str, ...]:
138
+ return tuple(
139
+ sorted({species for cylinder in self.cylinders for species in cylinder.composition})
140
+ )
141
+
142
+ def cylinder(self, name: str) -> Cylinder:
143
+ for cylinder in self.cylinders:
144
+ if cylinder.name == name:
145
+ return cylinder
146
+ raise InputError(f"Unknown cylinder {name!r}.")
147
+
148
+
149
+ class SolutionStatus(str, Enum):
150
+ EXACT = "exact"
151
+ APPROXIMATE = "approximate"
152
+ INFEASIBLE = "infeasible"
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class FeedResult:
157
+ """Common forward/inverse feed result."""
158
+
159
+ status: SolutionStatus
160
+ setpoints: Mapping[str, float]
161
+ total_flow: float
162
+ composition: Mapping[str, float]
163
+ component_flows: Mapping[str, float]
164
+ flow_unit: str
165
+ standard_conditions: StandardConditions
166
+ messages: tuple[str, ...] = field(default_factory=tuple)
167
+ target_composition: Mapping[str, float] | None = None
168
+ target_total_flow: float | None = None
169
+ maximum_composition_error: float | None = None
170
+ total_flow_error: float | None = None
171
+ ratios: Mapping[str, float | None] = field(default_factory=dict)
172
+ diluent_fraction: float | None = None