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.
@@ -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"
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m reactorcheck``."""
2
+
3
+ from reactorcheck.cli import main
4
+
5
+ raise SystemExit(main())
@@ -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
+ )