Skip to content

Commit 2169362

Browse files
committed
feat(assembly): StackAssembly dataclass with height/mass aggregation + JSON
Phase 2 of Visual Stack Designer (v0.4). Also fixes ruff I001 on components.py from previous commit (unblocks CI). src/assembly.py (NEW): - StackAssembly frozen dataclass — references all 11 component presets by name, resolves to concrete specs via *_spec() accessors with clear KeyError on typos - per_cell_height_m(), total_stack_height_m(), total_stack_mass_kg() - bpp_outer_dimensions_m() = sqrt(active_area) + 2·gasket.frame_width - bpp_resistance_ohm_m2() = rho·thickness — feeds CellSpec.r_bpp (existing field) - to_json / from_json roundtrip - default_assembly() = N=5, 50 cm², Nafion 212, IrO2, Pt/C, Ti-serpentine, ... tests/test_assembly.py (NEW): 9 tests covering preset resolution, KeyError on unknown name, height hand-calc + N-scaling, mass positivity + scaling, BPP outer dims, BPP-thickness-monotone r_bpp, JSON roundtrip, human-readable JSON. No change to electrochemistry.py: CellSpec.r_bpp already existed; assembly supplies the value via bpp_resistance_ohm_m2(). Minimal coupling surface. 117 tests green (was 108, +9). Ruff clean.
1 parent 71bd1ee commit 2169362

3 files changed

Lines changed: 361 additions & 1 deletion

File tree

src/assembly.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""
2+
StackAssembly — vollständige Bauteil-Konfiguration eines PEM-EC-Stacks.
3+
4+
Referenziert Presets aus src/materials.py (Membran, Katalysator, GDL) und
5+
src/components.py (BPP, Endplatte, Stromabnehmer, Dichtung, Tie-Rod) per
6+
Name. Aggregiert Höhe + Masse, bietet JSON-Save/Load, liefert BPP-Außenmaße
7+
für Visualisierung.
8+
9+
Physik-Kopplung in v0.4 bewusst minimal: nur bpp.bulk_resistivity * thickness
10+
fließt in CellSpec.r_bpp. Alles andere ist geometrische Information für die
11+
Visualisierung oder Display-only-Hook für v0.5+ (ADR-006).
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
from dataclasses import asdict, dataclass, field
18+
from pathlib import Path
19+
20+
from src.components import (
21+
BIPOLAR_PLATES,
22+
CURRENT_COLLECTORS,
23+
END_PLATES,
24+
GASKETS,
25+
TIE_RODS,
26+
BipolarPlateSpec,
27+
CurrentCollectorSpec,
28+
EndPlateSpec,
29+
GasketSpec,
30+
TieRodSpec,
31+
)
32+
from src.materials import (
33+
CATALYSTS_ANODE,
34+
CATALYSTS_CATHODE,
35+
GDL_ANODE,
36+
GDL_CATHODE,
37+
MEMBRANES,
38+
CatalystSpec,
39+
GDLSpec,
40+
MembraneSpec,
41+
)
42+
43+
44+
@dataclass(frozen=True)
45+
class StackAssembly:
46+
"""
47+
Vollständige Konfiguration eines Stacks. Alle Bauteile per Preset-Name.
48+
49+
active_area_m2 ist die geometrische Fläche pro Zelle (innerhalb des
50+
Gasket-Rahmens). BPP ist quadratisch angenommen mit Kante
51+
sqrt(active_area) + 2 * gasket.frame_width.
52+
"""
53+
54+
n_cells: int
55+
active_area_m2: float
56+
membrane: str
57+
anode_catalyst: str
58+
cathode_catalyst: str
59+
anode_gdl: str
60+
cathode_gdl: str
61+
bipolar_plate: str
62+
end_plate: str
63+
current_collector: str
64+
gasket: str
65+
tie_rod: str
66+
# Optional Catalyst-Layer-Dicke aus Loading+Density; default 10 µm reicht für Visualisierung.
67+
catalyst_layer_thickness_m: float = field(default=10e-6)
68+
69+
# ---------------- Preset resolution ---------------- #
70+
71+
def membrane_spec(self) -> MembraneSpec:
72+
return _resolve(MEMBRANES, self.membrane, "membrane")
73+
74+
def anode_catalyst_spec(self) -> CatalystSpec:
75+
return _resolve(CATALYSTS_ANODE, self.anode_catalyst, "anode_catalyst")
76+
77+
def cathode_catalyst_spec(self) -> CatalystSpec:
78+
return _resolve(CATALYSTS_CATHODE, self.cathode_catalyst, "cathode_catalyst")
79+
80+
def anode_gdl_spec(self) -> GDLSpec:
81+
return _resolve(GDL_ANODE, self.anode_gdl, "anode_gdl")
82+
83+
def cathode_gdl_spec(self) -> GDLSpec:
84+
return _resolve(GDL_CATHODE, self.cathode_gdl, "cathode_gdl")
85+
86+
def bipolar_plate_spec(self) -> BipolarPlateSpec:
87+
return _resolve(BIPOLAR_PLATES, self.bipolar_plate, "bipolar_plate")
88+
89+
def end_plate_spec(self) -> EndPlateSpec:
90+
return _resolve(END_PLATES, self.end_plate, "end_plate")
91+
92+
def current_collector_spec(self) -> CurrentCollectorSpec:
93+
return _resolve(CURRENT_COLLECTORS, self.current_collector, "current_collector")
94+
95+
def gasket_spec(self) -> GasketSpec:
96+
return _resolve(GASKETS, self.gasket, "gasket")
97+
98+
def tie_rod_spec(self) -> TieRodSpec:
99+
return _resolve(TIE_RODS, self.tie_rod, "tie_rod")
100+
101+
102+
# ---------------- Aggregation helpers ---------------- #
103+
104+
105+
def per_cell_height_m(a: StackAssembly) -> float:
106+
"""
107+
Höhe einer einzelnen Zelle [m] ohne Endplatten/Stromabnehmer.
108+
109+
Eine Zelle = 2 BPP + 2 GDL + 2 Catalyst-Layer + 1 Membran + 2 komprimierte
110+
Gaskets. Dies ist die "repeating unit" im Stack.
111+
"""
112+
m = a.membrane_spec()
113+
a_gdl = a.anode_gdl_spec()
114+
c_gdl = a.cathode_gdl_spec()
115+
bpp = a.bipolar_plate_spec()
116+
gask = a.gasket_spec()
117+
return (
118+
2 * bpp.thickness_m
119+
+ a_gdl.thickness_m
120+
+ c_gdl.thickness_m
121+
+ 2 * a.catalyst_layer_thickness_m
122+
+ m.thickness_m
123+
+ 2 * gask.compressed_thickness_m
124+
)
125+
126+
127+
def total_stack_height_m(a: StackAssembly) -> float:
128+
"""Σ Layer pro Zelle × N + 2 * Endplatte + 2 * Stromabnehmer [m]."""
129+
return (
130+
a.n_cells * per_cell_height_m(a)
131+
+ 2 * a.end_plate_spec().thickness_m
132+
+ 2 * a.current_collector_spec().thickness_m
133+
)
134+
135+
136+
def total_stack_mass_kg(a: StackAssembly) -> float:
137+
"""
138+
Näherung: Bauteil-Masse = Dichte × Volumen, Volumen = BPP-Fläche × Dicke.
139+
MEA-Masse (Membran/GDL/Catalyst) vernachlässigt (< 5 % der Gesamtmasse).
140+
Tie-Rods vernachlässigt (dünne Stangen, < 1 %).
141+
"""
142+
bpp_w, bpp_h = bpp_outer_dimensions_m(a)
143+
area_m2 = bpp_w * bpp_h
144+
bpp = a.bipolar_plate_spec()
145+
ep = a.end_plate_spec()
146+
cc = a.current_collector_spec()
147+
m_bpp = 2 * a.n_cells * bpp.density_kg_m3 * bpp.thickness_m * area_m2
148+
m_ep = 2 * ep.density_kg_m3 * ep.thickness_m * area_m2
149+
m_cc = 2 * cc.density_kg_m3 * cc.thickness_m * area_m2
150+
return m_bpp + m_ep + m_cc
151+
152+
153+
def bpp_outer_dimensions_m(a: StackAssembly) -> tuple[float, float]:
154+
"""
155+
Kantenlänge der quadratischen BPP [m, m].
156+
157+
= sqrt(active_area) + 2 * gasket.frame_width. Return-Tuple ist (width, height).
158+
Rechteckige Stacks sind v0.5-Scope.
159+
"""
160+
edge = a.active_area_m2**0.5 + 2 * a.gasket_spec().frame_width_m
161+
return edge, edge
162+
163+
164+
def bpp_resistance_ohm_m2(a: StackAssembly) -> float:
165+
"""
166+
Area-spezifischer BPP-Widerstand pro Zelle [Ω·m²].
167+
168+
Eine Zelle sieht zwei halbe BPPs (oben+unten Anode/Kathode-seitig geteilt),
169+
elektrisch also einen Gesamtweg durch rho * thickness. Dieses Ergebnis kann
170+
direkt in CellSpec.r_bpp eingesetzt werden.
171+
"""
172+
bpp = a.bipolar_plate_spec()
173+
return bpp.bulk_resistivity_ohm_m * bpp.thickness_m
174+
175+
176+
# ---------------- JSON Save/Load ---------------- #
177+
178+
179+
def to_json(a: StackAssembly, path: Path) -> None:
180+
"""Schreibt Assembly als JSON (alle Felder, keine Preset-Expansion)."""
181+
path = Path(path)
182+
path.write_text(json.dumps(asdict(a), indent=2), encoding="utf-8")
183+
184+
185+
def from_json(path: Path) -> StackAssembly:
186+
"""
187+
Liest Assembly aus JSON.
188+
189+
Preset-Namen werden beim Zugriff (*_spec()) validiert; ein KeyError
190+
hier bedeutet, die JSON hat einen nicht existierenden Preset-Namen.
191+
"""
192+
path = Path(path)
193+
data = json.loads(path.read_text(encoding="utf-8"))
194+
return StackAssembly(**data)
195+
196+
197+
# ---------------- Defaults ---------------- #
198+
199+
200+
def default_assembly() -> StackAssembly:
201+
"""Ausgangspunkt für UI: 5 Zellen, 50 cm², konservative Preset-Auswahl."""
202+
return StackAssembly(
203+
n_cells=5,
204+
active_area_m2=50e-4,
205+
membrane="Nafion 212",
206+
anode_catalyst="IrO2 (commercial)",
207+
cathode_catalyst="Pt/C (commercial)",
208+
anode_gdl="Ti felt (1 mm)",
209+
cathode_gdl="Carbon paper (Toray TGP-H-060)",
210+
bipolar_plate="Ti-serpentine (EC standard)",
211+
end_plate="SS-316L 20 mm",
212+
current_collector="Cu Au-plated 1 mm",
213+
gasket="PTFE 250 um",
214+
tie_rod="M10 x 8",
215+
)
216+
217+
218+
# ---------------- Internal ---------------- #
219+
220+
221+
def _resolve(preset_dict: dict, name: str, field_label: str):
222+
try:
223+
return preset_dict[name]
224+
except KeyError as err:
225+
raise KeyError(
226+
f"Assembly.{field_label}={name!r} nicht in Presets gefunden. "
227+
f"Verfügbar: {sorted(preset_dict.keys())}"
228+
) from err

src/components.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
from dataclasses import dataclass
1414

15-
1615
# -------------------------- Dataclasses -------------------------- #
1716

1817

tests/test_assembly.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Tests für src/assembly.py — Höhen-/Massen-Aggregation, JSON-Roundtrip."""
2+
3+
import json
4+
from dataclasses import asdict
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
from src.assembly import (
10+
StackAssembly,
11+
bpp_outer_dimensions_m,
12+
bpp_resistance_ohm_m2,
13+
default_assembly,
14+
from_json,
15+
per_cell_height_m,
16+
to_json,
17+
total_stack_height_m,
18+
total_stack_mass_kg,
19+
)
20+
21+
# ---------------- Preset resolution ---------------- #
22+
23+
24+
def test_default_assembly_resolves_all_presets():
25+
a = default_assembly()
26+
# Kein KeyError:
27+
a.membrane_spec()
28+
a.anode_catalyst_spec()
29+
a.cathode_catalyst_spec()
30+
a.anode_gdl_spec()
31+
a.cathode_gdl_spec()
32+
a.bipolar_plate_spec()
33+
a.end_plate_spec()
34+
a.current_collector_spec()
35+
a.gasket_spec()
36+
a.tie_rod_spec()
37+
38+
39+
def test_unknown_preset_raises_clear_keyerror():
40+
a = default_assembly()
41+
bad = StackAssembly(**{**asdict(a), "membrane": "Nafion-ThatDoesNotExist"})
42+
with pytest.raises(KeyError, match="membrane=.*Nafion-ThatDoesNotExist"):
43+
bad.membrane_spec()
44+
45+
46+
# ---------------- Height ---------------- #
47+
48+
49+
def test_per_cell_height_matches_hand_calc():
50+
a = default_assembly()
51+
m = a.membrane_spec()
52+
ag = a.anode_gdl_spec()
53+
cg = a.cathode_gdl_spec()
54+
bpp = a.bipolar_plate_spec()
55+
gk = a.gasket_spec()
56+
expected = (
57+
2 * bpp.thickness_m
58+
+ ag.thickness_m
59+
+ cg.thickness_m
60+
+ 2 * a.catalyst_layer_thickness_m
61+
+ m.thickness_m
62+
+ 2 * gk.compressed_thickness_m
63+
)
64+
assert per_cell_height_m(a) == pytest.approx(expected, rel=1e-12)
65+
66+
67+
def test_total_stack_height_linear_in_ncells():
68+
a5 = default_assembly() # n_cells=5
69+
a10 = StackAssembly(**{**asdict(a5), "n_cells": 10})
70+
dh = total_stack_height_m(a10) - total_stack_height_m(a5)
71+
# Zuwachs = 5 weitere Zellen (Endplatten + Stromabnehmer fallen weg):
72+
assert dh == pytest.approx(5 * per_cell_height_m(a5), rel=1e-12)
73+
74+
75+
# ---------------- Mass ---------------- #
76+
77+
78+
def test_total_stack_mass_positive_and_scales_with_ncells():
79+
a1 = default_assembly()
80+
a50 = StackAssembly(**{**asdict(a1), "n_cells": 50})
81+
m1 = total_stack_mass_kg(a1)
82+
m50 = total_stack_mass_kg(a50)
83+
assert m1 > 0 and m50 > m1
84+
# BPP-Masse skaliert linear mit N; Endplatten/CC sind Konstanten, also
85+
# (m50 - m1)/(50-1) == 2*rho_bpp*t_bpp*area (prüfen: positiv, endlich)
86+
per_cell_bpp_mass = (m50 - m1) / (50 - 1)
87+
assert per_cell_bpp_mass > 0
88+
89+
90+
# ---------------- Geometry ---------------- #
91+
92+
93+
def test_bpp_outer_dimensions_active_plus_frame():
94+
a = default_assembly()
95+
w, h = bpp_outer_dimensions_m(a)
96+
expected = a.active_area_m2**0.5 + 2 * a.gasket_spec().frame_width_m
97+
assert w == pytest.approx(expected, rel=1e-12)
98+
assert w == h # quadratisch
99+
100+
101+
# ---------------- Physics coupling ---------------- #
102+
103+
104+
def test_bpp_resistance_positive_and_monotone_in_thickness():
105+
"""Dickere BPP → höherer ohmscher Widerstand."""
106+
a_thin = default_assembly() # Ti 2 mm
107+
# Rebuild mit dickerer BPP (Graphit 3 mm) für Vergleich:
108+
a_thick = StackAssembly(
109+
**{**asdict(a_thin), "bipolar_plate": "Graphite-composite (PEMFC-like)"}
110+
)
111+
r_thin = bpp_resistance_ohm_m2(a_thin)
112+
r_thick = bpp_resistance_ohm_m2(a_thick)
113+
assert r_thin > 0 and r_thick > r_thin
114+
115+
116+
# ---------------- JSON Roundtrip ---------------- #
117+
118+
119+
def test_json_roundtrip(tmp_path: Path):
120+
a = default_assembly()
121+
path = tmp_path / "assembly.json"
122+
to_json(a, path)
123+
a2 = from_json(path)
124+
assert a == a2
125+
126+
127+
def test_json_file_is_human_readable(tmp_path: Path):
128+
a = default_assembly()
129+
path = tmp_path / "assembly.json"
130+
to_json(a, path)
131+
data = json.loads(path.read_text(encoding="utf-8"))
132+
assert data["n_cells"] == 5
133+
assert data["membrane"] == "Nafion 212"

0 commit comments

Comments
 (0)