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 +35 -0
- impregcalc/application/__init__.py +1 -0
- impregcalc/application/service.py +115 -0
- impregcalc/cli/__init__.py +5 -0
- impregcalc/cli/__main__.py +3 -0
- impregcalc/cli/main.py +90 -0
- impregcalc/core/__init__.py +1 -0
- impregcalc/core/calculator.py +307 -0
- impregcalc/core/errors.py +13 -0
- impregcalc/core/formula.py +201 -0
- impregcalc/core/models.py +224 -0
- impregcalc/gui/__init__.py +12 -0
- impregcalc/gui/__main__.py +3 -0
- impregcalc/gui/app.py +198 -0
- impregcalc/io/__init__.py +5 -0
- impregcalc/io/export.py +180 -0
- impregcalc/plotting/__init__.py +5 -0
- impregcalc/plotting/recipe.py +46 -0
- impregcalc/py.typed +1 -0
- impregcalc-0.1.0.dist-info/METADATA +212 -0
- impregcalc-0.1.0.dist-info/RECORD +24 -0
- impregcalc-0.1.0.dist-info/WHEEL +4 -0
- impregcalc-0.1.0.dist-info/entry_points.txt +3 -0
- impregcalc-0.1.0.dist-info/licenses/LICENSE +22 -0
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)
|
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."""
|