|
| 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 |
0 commit comments