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 +37 -0
- mfcblend/__main__.py +3 -0
- mfcblend/application/__init__.py +5 -0
- mfcblend/application/service.py +38 -0
- mfcblend/cli.py +79 -0
- mfcblend/core/__init__.py +31 -0
- mfcblend/core/derived.py +42 -0
- mfcblend/core/mixing.py +259 -0
- mfcblend/core/models.py +172 -0
- mfcblend/core/standard.py +47 -0
- mfcblend/gui/__init__.py +31 -0
- mfcblend/gui/__main__.py +45 -0
- mfcblend/gui/app.py +199 -0
- mfcblend/io/__init__.py +5 -0
- mfcblend/io/config.py +189 -0
- mfcblend/plotting/__init__.py +5 -0
- mfcblend/plotting/feed_diagram.py +139 -0
- mfcblend/py.typed +1 -0
- mfcblend-0.1.0.dist-info/METADATA +230 -0
- mfcblend-0.1.0.dist-info/RECORD +23 -0
- mfcblend-0.1.0.dist-info/WHEEL +4 -0
- mfcblend-0.1.0.dist-info/entry_points.txt +3 -0
- mfcblend-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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
|
+
]
|
mfcblend/core/derived.py
ADDED
|
@@ -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
|
mfcblend/core/mixing.py
ADDED
|
@@ -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
|
+
)
|
mfcblend/core/models.py
ADDED
|
@@ -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
|