impregcalc 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.
impregcalc/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """ImpregCalc public API.
2
+
3
+ Importing this package never imports Tkinter, matplotlib, or another GUI backend.
4
+ """
5
+
6
+ from .application.service import calculate_recipe, load_request
7
+ from .core.calculator import calculate
8
+ from .core.formula import parse_formula
9
+ from .core.models import (
10
+ LoadingBasis,
11
+ LoadingKind,
12
+ LoadingTarget,
13
+ PrecursorSpec,
14
+ PreparationMethod,
15
+ PromoterTarget,
16
+ RecipeRequest,
17
+ RecipeResult,
18
+ )
19
+
20
+ __all__ = [
21
+ "LoadingBasis",
22
+ "LoadingKind",
23
+ "LoadingTarget",
24
+ "PrecursorSpec",
25
+ "PreparationMethod",
26
+ "PromoterTarget",
27
+ "RecipeRequest",
28
+ "RecipeResult",
29
+ "calculate",
30
+ "calculate_recipe",
31
+ "load_request",
32
+ "parse_formula",
33
+ ]
34
+
35
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Application services shared by the CLI and GUI adapters."""
@@ -0,0 +1,115 @@
1
+ """Request decoding and calculation orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from impregcalc.core.calculator import calculate
11
+ from impregcalc.core.errors import CompositionError
12
+ from impregcalc.core.models import (
13
+ LoadingBasis,
14
+ LoadingKind,
15
+ LoadingTarget,
16
+ PrecursorSpec,
17
+ PreparationMethod,
18
+ PromoterTarget,
19
+ RecipeRequest,
20
+ RecipeResult,
21
+ )
22
+
23
+
24
+ def _mapping(value: object, context: str) -> Mapping[str, Any]:
25
+ if not isinstance(value, Mapping):
26
+ raise CompositionError(f"{context} must be a JSON object")
27
+ return value
28
+
29
+
30
+ def _precursor(data: Mapping[str, Any]) -> PrecursorSpec:
31
+ return PrecursorSpec(
32
+ formula=str(data["formula"]),
33
+ target_element=str(data["target_element"]),
34
+ purity_fraction=float(data.get("purity_fraction", 1.0)),
35
+ )
36
+
37
+
38
+ def request_from_dict(data: Mapping[str, Any]) -> RecipeRequest:
39
+ """Decode the documented JSON schema into a validated request."""
40
+
41
+ try:
42
+ targets = tuple(
43
+ LoadingTarget(
44
+ name=str(target_data["name"]),
45
+ kind=LoadingKind(str(target_data["kind"])),
46
+ basis=LoadingBasis(str(target_data["basis"])),
47
+ loading_percent=float(target_data["loading_percent"]),
48
+ active_formula=(
49
+ str(target_data["active_formula"])
50
+ if target_data.get("active_formula") is not None
51
+ else None
52
+ ),
53
+ precursor=_precursor(_mapping(target_data["precursor"], "target precursor")),
54
+ )
55
+ for item in data["targets"]
56
+ for target_data in [_mapping(item, "target")]
57
+ )
58
+ promoters = tuple(
59
+ PromoterTarget(
60
+ name=str(promoter_data["name"]),
61
+ target_element=str(promoter_data["target_element"]),
62
+ reference_target=str(promoter_data["reference_target"]),
63
+ atomic_ratio_to_reference=float(promoter_data["atomic_ratio_to_reference"]),
64
+ precursor=_precursor(_mapping(promoter_data["precursor"], "promoter precursor")),
65
+ )
66
+ for item in data.get("promoters", [])
67
+ for promoter_data in [_mapping(item, "promoter")]
68
+ )
69
+ fractions_raw = data.get("cycle_fractions")
70
+ fractions = (
71
+ tuple(float(item) for item in fractions_raw) if fractions_raw is not None else None
72
+ )
73
+ return RecipeRequest(
74
+ support_name=str(data["support_name"]),
75
+ support_mass_g=float(data["support_mass_g"]),
76
+ method=PreparationMethod(str(data["method"])),
77
+ targets=targets,
78
+ pore_volume_ml_g=(
79
+ float(data["pore_volume_ml_g"])
80
+ if data.get("pore_volume_ml_g") is not None
81
+ else None
82
+ ),
83
+ solution_volume_ml=(
84
+ float(data["solution_volume_ml"])
85
+ if data.get("solution_volume_ml") is not None
86
+ else None
87
+ ),
88
+ promoters=promoters,
89
+ cycles=int(data.get("cycles", 1)),
90
+ cycle_fractions=fractions,
91
+ )
92
+ except (KeyError, TypeError, ValueError) as exc:
93
+ if isinstance(exc, CompositionError):
94
+ raise
95
+ raise CompositionError(f"Invalid recipe document: {exc}") from exc
96
+
97
+
98
+ def load_request(path: str | Path) -> RecipeRequest:
99
+ """Load and validate a UTF-8 JSON recipe request."""
100
+
101
+ with Path(path).open(encoding="utf-8") as stream:
102
+ payload = json.load(stream)
103
+ return request_from_dict(_mapping(payload, "recipe document"))
104
+
105
+
106
+ def calculate_recipe(request: RecipeRequest | Mapping[str, Any] | str | Path) -> RecipeResult:
107
+ """Calculate from an API model, JSON-like mapping, or JSON file path."""
108
+
109
+ if isinstance(request, RecipeRequest):
110
+ decoded = request
111
+ elif isinstance(request, (str, Path)):
112
+ decoded = load_request(request)
113
+ else:
114
+ decoded = request_from_dict(request)
115
+ return calculate(decoded)
@@ -0,0 +1,5 @@
1
+ """Headless command-line adapter."""
2
+
3
+ from .main import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ raise SystemExit(main())
impregcalc/cli/main.py ADDED
@@ -0,0 +1,90 @@
1
+ """ImpregCalc command-line interface."""
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 impregcalc import __version__
12
+ from impregcalc.application.service import calculate_recipe, load_request
13
+ from impregcalc.core.errors import ImpregCalcError
14
+ from impregcalc.core.formula import parse_formula
15
+ from impregcalc.io import format_markdown, write_result
16
+
17
+
18
+ def _parser() -> argparse.ArgumentParser:
19
+ parser = argparse.ArgumentParser(
20
+ prog="impregcalc",
21
+ description="Explicit supported-catalyst impregnation recipe arithmetic.",
22
+ )
23
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
24
+ subparsers = parser.add_subparsers(dest="command", required=True)
25
+ calculate_parser = subparsers.add_parser("calculate", help="calculate and export a recipe")
26
+ calculate_parser.add_argument("input", type=Path, help="JSON recipe request")
27
+ calculate_parser.add_argument("--output", "-o", type=Path)
28
+ calculate_parser.add_argument("--plot", type=Path, help="optional PNG/SVG/PDF summary")
29
+ validate_parser = subparsers.add_parser("validate", help="validate and calculate a request")
30
+ validate_parser.add_argument("input", type=Path)
31
+ formula_parser = subparsers.add_parser("formula", help="inspect formula composition and mass")
32
+ formula_parser.add_argument("formula")
33
+ example_parser = subparsers.add_parser("example", help="print the bundled example request")
34
+ example_parser.add_argument("--output", "-o", type=Path)
35
+ return parser
36
+
37
+
38
+ def _example_path() -> Path:
39
+ source = Path("examples/co_ru_iwi.json")
40
+ if not source.is_file():
41
+ raise FileNotFoundError("Run this command from the source checkout to access the example")
42
+ return source
43
+
44
+
45
+ def main(argv: Sequence[str] | None = None) -> int:
46
+ parser = _parser()
47
+ args = parser.parse_args(argv)
48
+ try:
49
+ if args.command == "formula":
50
+ formula = parse_formula(args.formula)
51
+ print(
52
+ json.dumps(
53
+ {
54
+ "formula": formula.normalized,
55
+ "composition": formula.composition,
56
+ "molar_mass_g_mol": formula.molar_mass_g_mol,
57
+ },
58
+ indent=2,
59
+ )
60
+ )
61
+ return 0
62
+ if args.command == "example":
63
+ content = _example_path().read_text(encoding="utf-8")
64
+ if args.output:
65
+ args.output.write_text(content, encoding="utf-8")
66
+ else:
67
+ print(content, end="")
68
+ return 0
69
+ request = load_request(args.input)
70
+ result = calculate_recipe(request)
71
+ if args.command == "validate":
72
+ print(
73
+ f"VALID: mass closure residual = {result.mass_closure_residual_g:.3e} g; "
74
+ f"{len(result.requirements)} precursor(s)"
75
+ )
76
+ return 0
77
+ if args.output:
78
+ write_result(result, args.output)
79
+ print(f"Wrote {args.output}")
80
+ else:
81
+ print(format_markdown(result))
82
+ if args.plot:
83
+ from impregcalc.plotting import save_recipe_figure
84
+
85
+ save_recipe_figure(result, args.plot)
86
+ print(f"Wrote {args.plot}")
87
+ return 0
88
+ except (ImpregCalcError, OSError, RuntimeError, ValueError) as exc:
89
+ print(f"impregcalc: error: {exc}", file=sys.stderr)
90
+ return 2
@@ -0,0 +1 @@
1
+ """Scientific core: formula parsing, models, and mass-balance calculations."""
@@ -0,0 +1,307 @@
1
+ """Mass-conserving impregnation recipe calculations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .errors import CompositionError
8
+ from .formula import ParsedFormula, parse_formula
9
+ from .models import (
10
+ CyclePortion,
11
+ LoadingBasis,
12
+ LoadingKind,
13
+ LoadingTarget,
14
+ PrecursorRequirement,
15
+ PreparationMethod,
16
+ PromoterTarget,
17
+ RecipeRequest,
18
+ RecipeResult,
19
+ )
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class _LinearMass:
24
+ """Component mass represented as ``coefficient * final_mass + fixed_g``."""
25
+
26
+ coefficient: float
27
+ fixed_g: float
28
+
29
+ def at(self, final_mass_g: float) -> float:
30
+ return self.coefficient * final_mass_g + self.fixed_g
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class _ResolvedTarget:
35
+ target: LoadingTarget
36
+ active: ParsedFormula
37
+ precursor: ParsedFormula
38
+ mass: _LinearMass
39
+
40
+
41
+ def _active_formula(target: LoadingTarget) -> str:
42
+ if target.kind is LoadingKind.ELEMENTAL_METAL:
43
+ if target.active_formula and target.active_formula != target.precursor.target_element:
44
+ raise CompositionError("elemental_metal active_formula must equal the target element")
45
+ return target.precursor.target_element
46
+ if target.kind is LoadingKind.PRECURSOR:
47
+ if target.active_formula and (
48
+ parse_formula(target.active_formula).composition
49
+ != parse_formula(target.precursor.formula).composition
50
+ ):
51
+ raise CompositionError(
52
+ "A precursor loading may only use an active_formula equivalent to the precursor"
53
+ )
54
+ return target.precursor.formula
55
+ assert target.active_formula is not None
56
+ return target.active_formula
57
+
58
+
59
+ def _resolve_target(target: LoadingTarget, support_mass_g: float) -> _ResolvedTarget:
60
+ active = parse_formula(_active_formula(target))
61
+ precursor = parse_formula(target.precursor.formula)
62
+ element = target.precursor.target_element
63
+ if active.count(element) == 0:
64
+ raise CompositionError(f"Active formula {active.normalized} contains no {element}")
65
+ if precursor.count(element) == 0:
66
+ raise CompositionError(f"Precursor formula {precursor.normalized} contains no {element}")
67
+ fraction = target.loading_percent / 100.0
68
+ mass = (
69
+ _LinearMass(fraction, 0.0)
70
+ if target.basis is LoadingBasis.FINAL_CATALYST
71
+ else _LinearMass(0.0, fraction * support_mass_g)
72
+ )
73
+ return _ResolvedTarget(target, active, precursor, mass)
74
+
75
+
76
+ def _promoter_linear_mass(
77
+ promoter: PromoterTarget, reference: _ResolvedTarget
78
+ ) -> tuple[_LinearMass, ParsedFormula, ParsedFormula]:
79
+ precursor = parse_formula(promoter.precursor.formula)
80
+ if precursor.count(promoter.target_element) == 0:
81
+ raise CompositionError(
82
+ f"Promoter precursor {precursor.normalized} contains no {promoter.target_element}"
83
+ )
84
+ promoter_element = parse_formula(promoter.target_element)
85
+ reference_element = reference.target.precursor.target_element
86
+ reference_atoms_per_g = (
87
+ reference.active.count(reference_element) / reference.active.molar_mass_g_mol
88
+ )
89
+ promoter_g_per_reference_g = (
90
+ promoter.atomic_ratio_to_reference
91
+ * reference_atoms_per_g
92
+ * promoter_element.molar_mass_g_mol
93
+ )
94
+ return (
95
+ _LinearMass(
96
+ reference.mass.coefficient * promoter_g_per_reference_g,
97
+ reference.mass.fixed_g * promoter_g_per_reference_g,
98
+ ),
99
+ promoter_element,
100
+ precursor,
101
+ )
102
+
103
+
104
+ def _precursor_yield_g_per_g(
105
+ kind: LoadingKind,
106
+ active: ParsedFormula,
107
+ precursor: ParsedFormula,
108
+ target_element: str,
109
+ ) -> float:
110
+ if kind is LoadingKind.PRECURSOR:
111
+ return 1.0
112
+ active_from_one_mole_precursor_g = (
113
+ precursor.count(target_element) / active.count(target_element) * active.molar_mass_g_mol
114
+ )
115
+ return active_from_one_mole_precursor_g / precursor.molar_mass_g_mol
116
+
117
+
118
+ def _make_requirement(
119
+ *,
120
+ name: str,
121
+ kind: LoadingKind,
122
+ basis_description: str,
123
+ active: ParsedFormula,
124
+ precursor: ParsedFormula,
125
+ target_element: str,
126
+ purity: float,
127
+ active_mass_g: float,
128
+ volume_ml: float,
129
+ fractions: tuple[float, ...],
130
+ atomic_ratio_note: str | None = None,
131
+ ) -> PrecursorRequirement:
132
+ yield_fraction = _precursor_yield_g_per_g(kind, active, precursor, target_element)
133
+ if not 0 < yield_fraction <= 1.0 + 1e-12:
134
+ raise CompositionError("Computed precursor-to-active mass yield is outside (0, 1]")
135
+ pure_precursor_g = active_mass_g / yield_fraction
136
+ commercial_precursor_g = pure_precursor_g / purity
137
+ precursor_mol = pure_precursor_g / precursor.molar_mass_g_mol
138
+ element_fraction = (
139
+ active.count(target_element)
140
+ * parse_formula(target_element).molar_mass_g_mol
141
+ / active.molar_mass_g_mol
142
+ )
143
+ cycles = tuple(
144
+ CyclePortion(
145
+ cycle=index,
146
+ fraction=fraction,
147
+ precursor_mass_g=commercial_precursor_g * fraction,
148
+ solution_volume_ml=volume_ml * fraction,
149
+ )
150
+ for index, fraction in enumerate(fractions, start=1)
151
+ )
152
+ return PrecursorRequirement(
153
+ name=name,
154
+ kind=kind,
155
+ basis_description=basis_description,
156
+ active_formula=active.normalized,
157
+ target_element=target_element,
158
+ active_component_mass_g=active_mass_g,
159
+ target_element_mass_g=active_mass_g * element_fraction,
160
+ precursor_formula=precursor.normalized,
161
+ precursor_molar_mass_g_mol=precursor.molar_mass_g_mol,
162
+ precursor_purity_fraction=purity,
163
+ pure_precursor_mass_g=pure_precursor_g,
164
+ commercial_precursor_mass_g=commercial_precursor_g,
165
+ precursor_amount_mol=precursor_mol,
166
+ concentration_commercial_g_ml=commercial_precursor_g / volume_ml,
167
+ concentration_pure_mol_l=precursor_mol / (volume_ml / 1000.0),
168
+ cycle_portions=cycles,
169
+ atomic_ratio_note=atomic_ratio_note,
170
+ )
171
+
172
+
173
+ def calculate(request: RecipeRequest) -> RecipeResult:
174
+ """Calculate a supported-catalyst impregnation recipe.
175
+
176
+ The function performs recipe arithmetic only. It assumes quantitative retention
177
+ of the named target component and does not predict solubility, deposition,
178
+ drying redistribution, calcination yield, morphology, dispersion, or activity.
179
+ """
180
+
181
+ resolved = {
182
+ target.name: _resolve_target(target, request.support_mass_g) for target in request.targets
183
+ }
184
+ promoter_data: list[tuple[PromoterTarget, _LinearMass, ParsedFormula, ParsedFormula]] = []
185
+ for promoter in request.promoters:
186
+ try:
187
+ reference = resolved[promoter.reference_target]
188
+ except KeyError as exc:
189
+ raise CompositionError(
190
+ f"Promoter {promoter.name!r} refers to unknown target {promoter.reference_target!r}"
191
+ ) from exc
192
+ mass, active, precursor = _promoter_linear_mass(promoter, reference)
193
+ promoter_data.append((promoter, mass, active, precursor))
194
+
195
+ all_masses = [item.mass for item in resolved.values()] + [item[1] for item in promoter_data]
196
+ coefficient = sum(item.coefficient for item in all_masses)
197
+ fixed_g = sum(item.fixed_g for item in all_masses)
198
+ if coefficient >= 1.0 - 1e-12:
199
+ raise CompositionError(
200
+ "Final-catalyst-basis loadings (including derived promoter mass) leave no "
201
+ "positive support fraction"
202
+ )
203
+ final_mass_g = (request.support_mass_g + fixed_g) / (1.0 - coefficient)
204
+
205
+ nominal_volume_ml = (
206
+ request.support_mass_g * request.pore_volume_ml_g
207
+ if request.pore_volume_ml_g is not None
208
+ else None
209
+ )
210
+ if request.solution_volume_ml is not None:
211
+ selected_volume_ml = request.solution_volume_ml
212
+ elif nominal_volume_ml is not None:
213
+ selected_volume_ml = nominal_volume_ml
214
+ else: # guarded by RecipeRequest validation
215
+ raise CompositionError("A positive solution volume is required")
216
+
217
+ fractions = request.cycle_fractions or tuple(
218
+ 1.0 / request.cycles for _ in range(request.cycles)
219
+ )
220
+ requirements: list[PrecursorRequirement] = []
221
+ for item in resolved.values():
222
+ target = item.target
223
+ basis = (
224
+ f"{target.loading_percent:g} wt% {target.kind.value} on final-catalyst basis"
225
+ if target.basis is LoadingBasis.FINAL_CATALYST
226
+ else f"{target.loading_percent:g} wt% {target.kind.value} relative to support mass"
227
+ )
228
+ requirements.append(
229
+ _make_requirement(
230
+ name=target.name,
231
+ kind=target.kind,
232
+ basis_description=basis,
233
+ active=item.active,
234
+ precursor=item.precursor,
235
+ target_element=target.precursor.target_element,
236
+ purity=target.precursor.purity_fraction,
237
+ active_mass_g=item.mass.at(final_mass_g),
238
+ volume_ml=selected_volume_ml,
239
+ fractions=fractions,
240
+ )
241
+ )
242
+ for promoter, mass, active, precursor in promoter_data:
243
+ requirements.append(
244
+ _make_requirement(
245
+ name=promoter.name,
246
+ kind=LoadingKind.ELEMENTAL_METAL,
247
+ basis_description=(
248
+ f"elemental atomic ratio {promoter.target_element}:"
249
+ f"{resolved[promoter.reference_target].target.precursor.target_element} = "
250
+ f"{promoter.atomic_ratio_to_reference:g}:1"
251
+ ),
252
+ active=active,
253
+ precursor=precursor,
254
+ target_element=promoter.target_element,
255
+ purity=promoter.precursor.purity_fraction,
256
+ active_mass_g=mass.at(final_mass_g),
257
+ volume_ml=selected_volume_ml,
258
+ fractions=fractions,
259
+ atomic_ratio_note=(
260
+ f"Derived from {promoter.reference_target!r}; promoter is counted in the "
261
+ "final formulated mass."
262
+ ),
263
+ )
264
+ )
265
+
266
+ total_active_g = sum(row.active_component_mass_g for row in requirements)
267
+ closure = final_mass_g - request.support_mass_g - total_active_g
268
+ warnings = [
269
+ "No precursor-solubility database is applied; verify solution feasibility experimentally.",
270
+ "The calculation does not predict deposition, drying redistribution, calcination loss, "
271
+ "dispersion, morphology, or catalytic performance.",
272
+ "Multi-cycle output is an arithmetic split, not a claim of equivalent catalyst structure.",
273
+ ]
274
+ if request.method is PreparationMethod.INCIPIENT_WETNESS:
275
+ warnings.append(
276
+ "Pore-volume times support-mass is a nominal planning volume, not a universal wetting "
277
+ "truth; support pretreatment, solvent, and measurement method can change it."
278
+ )
279
+ if request.solution_volume_ml is not None and nominal_volume_ml is not None:
280
+ warnings.append(
281
+ "An explicit solution volume overrides the nominal pore-volume product."
282
+ )
283
+ if len(requirements) > 1:
284
+ warnings.append(
285
+ "For co-impregnation, each cycle's solution volume is shared by all precursor "
286
+ "rows and must not be summed across components."
287
+ )
288
+ assumptions = (
289
+ "Loading arithmetic assumes quantitative retention/conversion of the named target "
290
+ "component.",
291
+ "Final-catalyst basis means component mass divided by support plus all named retained "
292
+ "components in this model.",
293
+ "Support-relative basis means component mass divided by the initial dry support mass.",
294
+ "Purity correction treats the non-assay fraction as non-target material and does not add "
295
+ "it to the final catalyst mass balance.",
296
+ )
297
+ return RecipeResult(
298
+ request=request,
299
+ final_formulated_mass_g=final_mass_g,
300
+ total_active_component_mass_g=total_active_g,
301
+ nominal_pore_volume_ml=nominal_volume_ml,
302
+ selected_solution_volume_ml=selected_volume_ml,
303
+ requirements=tuple(requirements),
304
+ mass_closure_residual_g=closure,
305
+ warnings=tuple(warnings),
306
+ assumptions=assumptions,
307
+ )
@@ -0,0 +1,13 @@
1
+ """Domain-specific exceptions."""
2
+
3
+
4
+ class ImpregCalcError(ValueError):
5
+ """Base class for an invalid scientific input or unsupported convention."""
6
+
7
+
8
+ class FormulaError(ImpregCalcError):
9
+ """Raised when a chemical formula is outside the documented grammar."""
10
+
11
+
12
+ class CompositionError(ImpregCalcError):
13
+ """Raised when target loadings cannot define a physical mass balance."""