reactorcheck 0.2.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.
- reactorcheck/__init__.py +21 -0
- reactorcheck/__main__.py +5 -0
- reactorcheck/calculations.py +142 -0
- reactorcheck/cli.py +74 -0
- reactorcheck/composition.py +115 -0
- reactorcheck/gui.py +563 -0
- reactorcheck/gui_controller.py +51 -0
- reactorcheck/io.py +209 -0
- reactorcheck/models.py +180 -0
- reactorcheck/plotting.py +50 -0
- reactorcheck/py.typed +1 -0
- reactorcheck/qc.py +134 -0
- reactorcheck/service.py +511 -0
- reactorcheck/species.py +111 -0
- reactorcheck/steady.py +94 -0
- reactorcheck/units.py +88 -0
- reactorcheck-0.2.0.dist-info/METADATA +195 -0
- reactorcheck-0.2.0.dist-info/RECORD +21 -0
- reactorcheck-0.2.0.dist-info/WHEEL +4 -0
- reactorcheck-0.2.0.dist-info/entry_points.txt +3 -0
- reactorcheck-0.2.0.dist-info/licenses/LICENSE +21 -0
reactorcheck/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""ReactorCheck public API."""
|
|
2
|
+
|
|
3
|
+
from reactorcheck.models import (
|
|
4
|
+
AnalysisConfig,
|
|
5
|
+
AnalysisResult,
|
|
6
|
+
ChannelMapping,
|
|
7
|
+
CompositionSettings,
|
|
8
|
+
Species,
|
|
9
|
+
)
|
|
10
|
+
from reactorcheck.service import analyze_file, analyze_frame
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AnalysisConfig",
|
|
14
|
+
"AnalysisResult",
|
|
15
|
+
"ChannelMapping",
|
|
16
|
+
"CompositionSettings",
|
|
17
|
+
"Species",
|
|
18
|
+
"analyze_file",
|
|
19
|
+
"analyze_frame",
|
|
20
|
+
]
|
|
21
|
+
__version__ = "0.2.0"
|
reactorcheck/__main__.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Pure scientific calculations for catalytic reactor data.
|
|
2
|
+
|
|
3
|
+
All flow arrays accepted here are mol/h. Dimensioned inputs are normalized by
|
|
4
|
+
the service layer before entering this module, keeping equations explicit and
|
|
5
|
+
testable without any GUI or file-I/O state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from numpy.typing import ArrayLike, NDArray
|
|
14
|
+
|
|
15
|
+
from reactorcheck.models import Species
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _arrays(
|
|
19
|
+
numerator: ArrayLike, denominator: ArrayLike
|
|
20
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
21
|
+
return np.asarray(numerator, dtype=float), np.asarray(denominator, dtype=float)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def safe_ratio(numerator: ArrayLike, denominator: ArrayLike) -> NDArray[np.float64]:
|
|
25
|
+
"""Divide while representing a zero/invalid denominator as NaN."""
|
|
26
|
+
top, bottom = _arrays(numerator, denominator)
|
|
27
|
+
result = np.full(np.broadcast_shapes(top.shape, bottom.shape), np.nan, dtype=float)
|
|
28
|
+
valid = np.isfinite(top) & np.isfinite(bottom) & (bottom != 0.0)
|
|
29
|
+
np.divide(top, bottom, out=result, where=valid)
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def conversion(inlet: ArrayLike, outlet: ArrayLike) -> NDArray[np.float64]:
|
|
34
|
+
"""Reactant conversion X = (F_in - F_out) / F_in."""
|
|
35
|
+
inlet_array, outlet_array = _arrays(inlet, outlet)
|
|
36
|
+
return safe_ratio(inlet_array - outlet_array, inlet_array)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def net_formation(inlet: ArrayLike, outlet: ArrayLike) -> NDArray[np.float64]:
|
|
40
|
+
"""Net species formation F_out - F_in in mol/h."""
|
|
41
|
+
inlet_array, outlet_array = _arrays(inlet, outlet)
|
|
42
|
+
return outlet_array - inlet_array
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def molar_selectivity(
|
|
46
|
+
product_formation: ArrayLike, reactant_consumption: ArrayLike
|
|
47
|
+
) -> NDArray[np.float64]:
|
|
48
|
+
"""Molar selectivity = mol product formed / mol reference reactant consumed."""
|
|
49
|
+
return safe_ratio(product_formation, reactant_consumption)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def carbon_selectivity(
|
|
53
|
+
product_formation: ArrayLike,
|
|
54
|
+
product_carbon_number: int,
|
|
55
|
+
reactant_consumption: ArrayLike,
|
|
56
|
+
reactant_carbon_number: int,
|
|
57
|
+
) -> NDArray[np.float64]:
|
|
58
|
+
"""Carbon selectivity based on carbon atoms in net product and consumed feed."""
|
|
59
|
+
if reactant_carbon_number <= 0:
|
|
60
|
+
return np.full(np.asarray(product_formation).shape, np.nan, dtype=float)
|
|
61
|
+
return safe_ratio(
|
|
62
|
+
np.asarray(product_formation) * product_carbon_number,
|
|
63
|
+
np.asarray(reactant_consumption) * reactant_carbon_number,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def direct_yield(product_formation: ArrayLike, reactant_inlet: ArrayLike) -> NDArray[np.float64]:
|
|
68
|
+
"""Molar product yield = net mol product formed / mol reference reactant fed.
|
|
69
|
+
|
|
70
|
+
Under the same molar basis, this is identically conversion times molar
|
|
71
|
+
selectivity. Keeping the direct equation permits an independent consistency
|
|
72
|
+
check in the service and tests.
|
|
73
|
+
"""
|
|
74
|
+
return safe_ratio(product_formation, reactant_inlet)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def element_balance(
|
|
78
|
+
inlet_flows: Mapping[str, NDArray[np.float64]],
|
|
79
|
+
outlet_flows: Mapping[str, NDArray[np.float64]],
|
|
80
|
+
species: Mapping[str, Species],
|
|
81
|
+
element: str,
|
|
82
|
+
) -> NDArray[np.float64]:
|
|
83
|
+
"""Element recovery = total elemental molar outflow / inflow."""
|
|
84
|
+
sample = next(iter(outlet_flows.values()))
|
|
85
|
+
element_in = np.zeros_like(sample, dtype=float)
|
|
86
|
+
element_out = np.zeros_like(sample, dtype=float)
|
|
87
|
+
for name, item in species.items():
|
|
88
|
+
count = item.composition.get(element, 0)
|
|
89
|
+
element_in += inlet_flows[name] * count
|
|
90
|
+
element_out += outlet_flows[name] * count
|
|
91
|
+
return safe_ratio(element_out, element_in)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def mass_balance(
|
|
95
|
+
inlet_flows: Mapping[str, NDArray[np.float64]],
|
|
96
|
+
outlet_flows: Mapping[str, NDArray[np.float64]],
|
|
97
|
+
species: Mapping[str, Species],
|
|
98
|
+
) -> NDArray[np.float64]:
|
|
99
|
+
"""Measured-species mass recovery = Σ(F_out MW) / Σ(F_in MW).
|
|
100
|
+
|
|
101
|
+
This is a measured-stream closure, not a total plant balance when unmeasured
|
|
102
|
+
species exist; the report labels it accordingly.
|
|
103
|
+
"""
|
|
104
|
+
sample = next(iter(outlet_flows.values()))
|
|
105
|
+
mass_in = np.zeros_like(sample, dtype=float)
|
|
106
|
+
mass_out = np.zeros_like(sample, dtype=float)
|
|
107
|
+
for name, item in species.items():
|
|
108
|
+
mass_in += inlet_flows[name] * item.molecular_weight_g_mol
|
|
109
|
+
mass_out += outlet_flows[name] * item.molecular_weight_g_mol
|
|
110
|
+
return safe_ratio(mass_out, mass_in)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def reaction_rate_mol_gcat_h(
|
|
114
|
+
formation_mol_h: ArrayLike, catalyst_mass_g: float
|
|
115
|
+
) -> NDArray[np.float64]:
|
|
116
|
+
"""Net formation rate r = ΔF / m_cat in mol g_cat⁻¹ h⁻¹."""
|
|
117
|
+
if catalyst_mass_g <= 0:
|
|
118
|
+
raise ValueError("Catalyst mass must be positive")
|
|
119
|
+
return np.asarray(formation_mol_h, dtype=float) / catalyst_mass_g
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sty_gproduct_gcat_h(
|
|
123
|
+
formation_mol_h: ArrayLike,
|
|
124
|
+
molecular_weight_g_mol: float,
|
|
125
|
+
catalyst_mass_g: float,
|
|
126
|
+
) -> NDArray[np.float64]:
|
|
127
|
+
"""Mass-based STY = ΔF_product MW_product / m_cat."""
|
|
128
|
+
return reaction_rate_mol_gcat_h(formation_mol_h, catalyst_mass_g) * molecular_weight_g_mol
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def ghsv_h_inverse(volumetric_feed_ml_h: float, reactor_volume_ml: float) -> float:
|
|
132
|
+
"""GHSV = standard volumetric feed rate / packed reactor volume, h⁻¹."""
|
|
133
|
+
if volumetric_feed_ml_h < 0 or reactor_volume_ml <= 0:
|
|
134
|
+
raise ValueError("Feed flow cannot be negative and reactor volume must be positive")
|
|
135
|
+
return volumetric_feed_ml_h / reactor_volume_ml
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def whsv_h_inverse(mass_feed_g_h: float, catalyst_mass_g: float) -> float:
|
|
139
|
+
"""WHSV = total feed mass flow / catalyst mass, h⁻¹."""
|
|
140
|
+
if mass_feed_g_h < 0 or catalyst_mass_g <= 0:
|
|
141
|
+
raise ValueError("Feed flow cannot be negative and catalyst mass must be positive")
|
|
142
|
+
return mass_feed_g_h / catalyst_mass_g
|
reactorcheck/cli.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from reactorcheck import __version__
|
|
11
|
+
from reactorcheck.io import (
|
|
12
|
+
export_csv_bundle,
|
|
13
|
+
export_excel,
|
|
14
|
+
load_config,
|
|
15
|
+
read_table,
|
|
16
|
+
suggest_mappings,
|
|
17
|
+
)
|
|
18
|
+
from reactorcheck.plotting import save_result_plot
|
|
19
|
+
from reactorcheck.service import analyze_file
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
23
|
+
"""Create the public CLI parser."""
|
|
24
|
+
parser = argparse.ArgumentParser(prog="reactorcheck", description=__doc__)
|
|
25
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
26
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
27
|
+
analyze = subparsers.add_parser("analyze", help="Analyze reactor CSV/XLSX data")
|
|
28
|
+
analyze.add_argument("data", type=Path)
|
|
29
|
+
analyze.add_argument("--config", "-c", type=Path, required=True)
|
|
30
|
+
analyze.add_argument(
|
|
31
|
+
"--output", "-o", type=Path, required=True, help=".xlsx file or CSV bundle directory"
|
|
32
|
+
)
|
|
33
|
+
analyze.add_argument("--plot", type=Path)
|
|
34
|
+
suggest = subparsers.add_parser("suggest", help="Suggest species mappings for user review")
|
|
35
|
+
suggest.add_argument("data", type=Path)
|
|
36
|
+
suggest.add_argument("--output", "-o", type=Path)
|
|
37
|
+
subparsers.add_parser("gui", help="Launch the desktop GUI")
|
|
38
|
+
return parser
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(argv: list[str] | None = None) -> int:
|
|
42
|
+
"""Run CLI and return a process exit code."""
|
|
43
|
+
args = build_parser().parse_args(argv)
|
|
44
|
+
try:
|
|
45
|
+
if args.command == "analyze":
|
|
46
|
+
result = analyze_file(args.data, load_config(args.config))
|
|
47
|
+
if args.output.suffix.lower() == ".xlsx":
|
|
48
|
+
export_excel(result, args.output)
|
|
49
|
+
else:
|
|
50
|
+
export_csv_bundle(result, args.output)
|
|
51
|
+
if args.plot:
|
|
52
|
+
save_result_plot(result, args.plot)
|
|
53
|
+
print(f"Analyzed {len(result.processed)} rows; QC issues: {len(result.qc_issues)}")
|
|
54
|
+
return 0
|
|
55
|
+
if args.command == "suggest":
|
|
56
|
+
text = json.dumps(suggest_mappings(read_table(args.data)), indent=2) + "\n"
|
|
57
|
+
if args.output:
|
|
58
|
+
args.output.write_text(text, encoding="utf-8")
|
|
59
|
+
else:
|
|
60
|
+
print(text, end="")
|
|
61
|
+
return 0
|
|
62
|
+
if args.command == "gui":
|
|
63
|
+
from reactorcheck.gui import main as gui_main
|
|
64
|
+
|
|
65
|
+
gui_main()
|
|
66
|
+
return 0
|
|
67
|
+
except (OSError, ValueError) as exc:
|
|
68
|
+
print(f"reactorcheck: error: {exc}", file=sys.stderr)
|
|
69
|
+
return 2
|
|
70
|
+
return 2
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Mole-fraction normalization and internal-standard flow reconstruction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import ArrayLike, NDArray
|
|
10
|
+
|
|
11
|
+
from reactorcheck.models import CompositionTreatment
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CompositionError(ValueError):
|
|
15
|
+
"""Raised when a composition cannot be physically reconstructed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CompositionResolution:
|
|
20
|
+
"""Raw/used fractions and reconstructed outlet molar flows."""
|
|
21
|
+
|
|
22
|
+
raw_fractions: dict[str, NDArray[np.float64]]
|
|
23
|
+
used_fractions: dict[str, NDArray[np.float64]]
|
|
24
|
+
outlet_mol_h: dict[str, NDArray[np.float64]]
|
|
25
|
+
raw_sum: NDArray[np.float64]
|
|
26
|
+
used_sum: NDArray[np.float64]
|
|
27
|
+
normalization_applied: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def to_fraction(values: ArrayLike, unit: str) -> NDArray[np.float64]:
|
|
31
|
+
"""Convert an explicitly labeled composition unit to mole fraction."""
|
|
32
|
+
normalized = unit.strip().lower().replace(" ", "_")
|
|
33
|
+
factors = {
|
|
34
|
+
"fraction": 1.0,
|
|
35
|
+
"mole_fraction": 1.0,
|
|
36
|
+
"mol/mol": 1.0,
|
|
37
|
+
"1": 1.0,
|
|
38
|
+
"%": 0.01,
|
|
39
|
+
"percent": 0.01,
|
|
40
|
+
"mol%": 0.01,
|
|
41
|
+
"mole_percent": 0.01,
|
|
42
|
+
"ppm": 1e-6,
|
|
43
|
+
"ppmv": 1e-6,
|
|
44
|
+
}
|
|
45
|
+
if normalized not in factors:
|
|
46
|
+
raise CompositionError(
|
|
47
|
+
f"Unsupported composition unit {unit!r}; use fraction, percent/mol%, or ppm/ppmv"
|
|
48
|
+
)
|
|
49
|
+
return np.asarray(values, dtype=float) * factors[normalized]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def reconstruct_with_internal_standard(
|
|
53
|
+
measurements: Mapping[str, ArrayLike],
|
|
54
|
+
units: Mapping[str, str],
|
|
55
|
+
normalization: CompositionTreatment,
|
|
56
|
+
internal_standard: str,
|
|
57
|
+
standard_feed_mol_h: float,
|
|
58
|
+
) -> CompositionResolution:
|
|
59
|
+
"""Reconstruct F_out,i = y_i/y_std * F_std,in.
|
|
60
|
+
|
|
61
|
+
The caller must have already validated the user's explicit assumption that
|
|
62
|
+
the standard is nonreactive, so F_std,out equals F_std,in. Normalization is
|
|
63
|
+
applied only when ``normalization`` is ``normalized``. Wet/dry water basis is
|
|
64
|
+
validated independently by the domain model and does not alter the ratio.
|
|
65
|
+
Global normalization must not alter reconstructed flows; retaining raw and
|
|
66
|
+
used values makes that invariant auditable.
|
|
67
|
+
"""
|
|
68
|
+
if internal_standard not in measurements:
|
|
69
|
+
raise CompositionError("Internal-standard composition channel is missing")
|
|
70
|
+
if standard_feed_mol_h <= 0 or not np.isfinite(standard_feed_mol_h):
|
|
71
|
+
raise CompositionError("Internal-standard feed flow must be finite and positive")
|
|
72
|
+
raw = {name: to_fraction(values, units[name]) for name, values in measurements.items()}
|
|
73
|
+
lengths = {len(values) for values in raw.values()}
|
|
74
|
+
if len(lengths) != 1:
|
|
75
|
+
raise CompositionError("Composition channels have inconsistent lengths")
|
|
76
|
+
standard_values = raw[internal_standard]
|
|
77
|
+
invalid_standard = ~np.isfinite(standard_values) | (standard_values <= 0)
|
|
78
|
+
if invalid_standard.any():
|
|
79
|
+
rows = tuple(int(value) for value in np.flatnonzero(invalid_standard)[:20])
|
|
80
|
+
raise CompositionError(
|
|
81
|
+
f"Internal-standard fraction must be finite and positive; invalid rows={rows}"
|
|
82
|
+
)
|
|
83
|
+
negative = {
|
|
84
|
+
name: tuple(int(value) for value in np.flatnonzero(values < 0)[:20])
|
|
85
|
+
for name, values in raw.items()
|
|
86
|
+
if (values < 0).any()
|
|
87
|
+
}
|
|
88
|
+
if negative:
|
|
89
|
+
raise CompositionError(f"Negative mole fractions are physically invalid: {negative}")
|
|
90
|
+
|
|
91
|
+
stack = np.vstack(tuple(raw.values()))
|
|
92
|
+
raw_sum = np.sum(stack, axis=0)
|
|
93
|
+
normalization_applied = normalization == "normalized"
|
|
94
|
+
if normalization_applied:
|
|
95
|
+
invalid_sum = ~np.isfinite(raw_sum) | (raw_sum <= 0)
|
|
96
|
+
if invalid_sum.any():
|
|
97
|
+
rows = tuple(int(value) for value in np.flatnonzero(invalid_sum)[:20])
|
|
98
|
+
raise CompositionError(
|
|
99
|
+
"Composition sum must be finite and positive for normalization; "
|
|
100
|
+
f"invalid rows={rows}"
|
|
101
|
+
)
|
|
102
|
+
used = {name: values / raw_sum for name, values in raw.items()}
|
|
103
|
+
else:
|
|
104
|
+
used = {name: values.copy() for name, values in raw.items()}
|
|
105
|
+
used_sum = np.sum(np.vstack(tuple(used.values())), axis=0)
|
|
106
|
+
used_standard = used[internal_standard]
|
|
107
|
+
outlet = {name: (values / used_standard) * standard_feed_mol_h for name, values in used.items()}
|
|
108
|
+
return CompositionResolution(
|
|
109
|
+
raw_fractions=raw,
|
|
110
|
+
used_fractions=used,
|
|
111
|
+
outlet_mol_h=outlet,
|
|
112
|
+
raw_sum=np.asarray(raw_sum, dtype=float),
|
|
113
|
+
used_sum=np.asarray(used_sum, dtype=float),
|
|
114
|
+
normalization_applied=normalization_applied,
|
|
115
|
+
)
|