Skip to content

Commit 19af358

Browse files
Tools00claude
andcommitted
feat(assembly): rectangular stacks + ΔP/pump UI (v0.5 Phase 2)
- StackAssembly: add `aspect_ratio` (default 1.0, area-preserving) + `active_dimensions_m(a) → (w, h)`. BPP outer follows as rectangle. - Fluid wire-up: `assembly_pressure_drop` + `assembly_pump_power_w` take current + T + λ, return ΔP result / stack pump [W]. - Streamlit: aspect_ratio slider in sidebar, ΔP/pump block in Assembly tab (λ, η_pump inputs, metrics, turbulent-regime warning). - Visualization: `_draw_flow_pattern` takes (width, height) not edge; `draw_bpp_top_view` accepts optional aspect_ratio (backward compat). - Legacy JSONs without aspect_ratio load as square (default field). - +13 tests (total 157 passing), ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent fd8b71e commit 19af358

6 files changed

Lines changed: 421 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,35 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · SemVer.
55

66
## [Unreleased]
77

8-
### Planned (next)
9-
- Tafel-Plot (semi-log) in der UI zur Visualisierung der Validation
10-
- Effizienz-Kurve η_energy(j) neben Polarisationskurve
11-
- Vergleichs-Modus (2 Zellen/Stacks nebeneinander)
12-
- 2. Validation gegen experimentelle Paper-Kurve
8+
### Added (v0.5 Phase 2 — rectangular stacks + ΔP in UI)
9+
- **`aspect_ratio` auf `StackAssembly`** (`src/assembly.py`): dimensionslos,
10+
default 1.0 (quadratisch = v0.4-Verhalten). `active_dimensions_m(a) → (w, h)`
11+
mit `w·h = active_area_m2` und `w/h = aspect_ratio`. BPP-Außenmaße folgen
12+
als `(w + 2·frame, h + 2·frame)` — echte rechteckige Plates.
13+
- v0.4-JSONs ohne `aspect_ratio`-Feld laden weiterhin als quadratisch (Default).
14+
- **Fluid-Kopplung** (`src/assembly.py`): `assembly_pressure_drop(a, *, current_a,
15+
temperature_k, stoich_ratio)` berechnet ΔP über `fluid.pressure_drop` mit
16+
aktiven Dimensionen aus `aspect_ratio`. `assembly_pump_power_w(...)` liefert
17+
Stack-Pumpenleistung (alle N Zellen hydraulisch parallel, Q_total = N·Q_cell).
18+
- **Streamlit Assembly-Tab**: neuer Block „Flow field — pressure drop & pump
19+
power" mit λ- und η_pump-Slidern, Metrics für ΔP [kPa], v [cm/s], Re, Pump
20+
[W] + Parasit-Anteil der Stack-Leistung. Turbulenter Regime (Re > 2000)
21+
liefert saubere Warnung statt Crash.
22+
- **Streamlit Sidebar**: `aspect_ratio`-Slider 0.25…4.0; Caption zeigt
23+
resultierende w × h der aktiven Fläche. Area bleibt beim Drehen konstant.
24+
- **13 neue Tests** (total 157): aspect_ratio area-preserving, ΔP-Linearität
25+
in I, Pump-Power ∝ N, serpentine > parallel ΔP, JSON-Roundtrip mit
26+
aspect_ratio, Legacy-JSON-Kompat, rechteckige BPP-Visualization.
27+
- **Rechteckiges Flow-Field-Rendering** (`src/visualization.py`):
28+
`_draw_flow_pattern` nimmt `width_mm`+`height_mm` statt `edge_mm`. Bei
29+
aspect_ratio ≠ 1.0 zeichnen alle drei Pattern (parallel, serpentine,
30+
interdigitated) auf rechteckiger Fläche.
31+
32+
### Planned (next, v0.5 Phase 3 + release)
33+
- Validation gegen Bernt et al. (2020) *Chem. Ing. Tech.* 92(1-2), 31–39, Fig. 1
34+
(80 °C, Nafion 212, 2 mg Ir/cm², 0.35 mg Pt/cm²; RMSE-Target 40 mV)
35+
- ADR-007 für v0.5-Architektur-Entscheidungen
36+
- v0.5.0 Release-Commit + README-Update
1337

1438
## [0.4.1] — 2026-04-20
1539

src/assembly.py

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@
2929
GasketSpec,
3030
TieRodSpec,
3131
)
32+
from src.fluid import (
33+
PressureDropResult,
34+
pressure_drop,
35+
pump_power_w,
36+
stoichiometric_water_flow_m3_s,
37+
)
3238
from src.materials import (
3339
CATALYSTS_ANODE,
3440
CATALYSTS_CATHODE,
@@ -47,8 +53,10 @@ class StackAssembly:
4753
Vollständige Konfiguration eines Stacks. Alle Bauteile per Preset-Name.
4854
4955
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.
56+
Gasket-Rahmens). aspect_ratio (v0.5) = width/height der aktiven Fläche,
57+
dimensionslos, > 0. aspect_ratio=1.0 ergibt quadratisch (v0.4-Verhalten).
58+
aspect_ratio=2.0 → Breite = 2× Höhe, gleiche Gesamtfläche. BPP-Außenkante
59+
folgt als (w + 2·frame, h + 2·frame).
5260
"""
5361

5462
n_cells: int
@@ -65,6 +73,8 @@ class StackAssembly:
6573
tie_rod: str
6674
# Optional Catalyst-Layer-Dicke aus Loading+Density; default 10 µm reicht für Visualisierung.
6775
catalyst_layer_thickness_m: float = field(default=10e-6)
76+
# v0.5: Aspect-Ratio der aktiven Fläche (width/height). Default 1.0 = quadratisch.
77+
aspect_ratio: float = field(default=1.0)
6878

6979
# ---------------- Preset resolution ---------------- #
7080

@@ -150,15 +160,31 @@ def total_stack_mass_kg(a: StackAssembly) -> float:
150160
return m_bpp + m_ep + m_cc
151161

152162

163+
def active_dimensions_m(a: StackAssembly) -> tuple[float, float]:
164+
"""
165+
Aktive Fläche (width, height) [m]. Bei aspect_ratio=1.0 quadratisch.
166+
167+
Beziehung: w · h = active_area_m2 und w / h = aspect_ratio
168+
→ w = sqrt(A · aspect_ratio), h = sqrt(A / aspect_ratio)
169+
170+
@raises ValueError: wenn aspect_ratio ≤ 0.
171+
"""
172+
if a.aspect_ratio <= 0:
173+
raise ValueError(f"aspect_ratio={a.aspect_ratio} must be positive")
174+
w = (a.active_area_m2 * a.aspect_ratio) ** 0.5
175+
h = (a.active_area_m2 / a.aspect_ratio) ** 0.5
176+
return w, h
177+
178+
153179
def bpp_outer_dimensions_m(a: StackAssembly) -> tuple[float, float]:
154180
"""
155-
Kantenlänge der quadratischen BPP [m, m].
181+
BPP-Außenmaße (width, height) [m] = active + 2·frame_width in jeder Richtung.
156182
157-
= sqrt(active_area) + 2 * gasket.frame_width. Return-Tuple ist (width, height).
158-
Rechteckige Stacks sind v0.5-Scope.
183+
Bei aspect_ratio=1.0 ist w == h (quadratische BPP); ansonsten rechteckig.
159184
"""
160-
edge = a.active_area_m2**0.5 + 2 * a.gasket_spec().frame_width_m
161-
return edge, edge
185+
aw, ah = active_dimensions_m(a)
186+
fw = a.gasket_spec().frame_width_m
187+
return aw + 2 * fw, ah + 2 * fw
162188

163189

164190
def bpp_resistance_ohm_m2(a: StackAssembly) -> float:
@@ -173,6 +199,82 @@ def bpp_resistance_ohm_m2(a: StackAssembly) -> float:
173199
return bpp.bulk_resistivity_ohm_m * bpp.thickness_m
174200

175201

202+
# ---------------- Fluid coupling (v0.5) ---------------- #
203+
204+
205+
def assembly_pressure_drop(
206+
a: StackAssembly,
207+
*,
208+
current_a: float,
209+
temperature_k: float,
210+
stoich_ratio: float = 50.0,
211+
) -> PressureDropResult:
212+
"""
213+
Druckabfall im Flow-Field einer Zelle bei Betriebsbedingungen [Pa].
214+
215+
Volumenstrom pro Zelle wird aus Stromstärke und Stöchiometrie berechnet
216+
(`fluid.stoichiometric_water_flow_m3_s`), anschließend laminar durch das
217+
BPP-Flow-Field geleitet (`fluid.pressure_drop`).
218+
219+
Args:
220+
current_a: Zellstrom [A] (NICHT Stack-Strom — im Stack sind alle
221+
Zellen elektrisch in Serie und sehen denselben I).
222+
temperature_k: Betriebstemperatur [K] für Wasser-Eigenschaften + Stoich.
223+
stoich_ratio: λ = Q_feed / Q_stoich; default 50 (typ. PEM-EC).
224+
225+
Raises:
226+
ValueError: wenn Re > 2000 (turbulent, nicht implementiert in v0.5).
227+
228+
@ref: siehe src/fluid.py.
229+
"""
230+
bpp = a.bipolar_plate_spec()
231+
aw, ah = active_dimensions_m(a)
232+
q_cell = stoichiometric_water_flow_m3_s(
233+
current_a=current_a,
234+
stoich_ratio=stoich_ratio,
235+
temperature_k=temperature_k,
236+
)
237+
return pressure_drop(
238+
flow_pattern=bpp.flow_pattern,
239+
channel_width_m=bpp.channel_width_m,
240+
channel_depth_m=bpp.channel_depth_m,
241+
channel_pitch_m=bpp.channel_pitch_m,
242+
active_width_m=aw,
243+
active_height_m=ah,
244+
volumetric_flow_per_cell_m3_s=q_cell,
245+
temperature_k=temperature_k,
246+
)
247+
248+
249+
def assembly_pump_power_w(
250+
a: StackAssembly,
251+
*,
252+
current_a: float,
253+
temperature_k: float,
254+
stoich_ratio: float = 50.0,
255+
eta_pump: float = 0.5,
256+
) -> float:
257+
"""
258+
Hydraulische Pumpenleistung für den gesamten Stack [W].
259+
260+
P_pump,stack = N_cells · ΔP_cell · Q_cell / η_pump.
261+
(Alle Zellen sind hydraulisch parallel geschaltet, sehen denselben ΔP,
262+
erhalten jeweils eigenen Q_cell; Stack-Pumpe liefert Summe.)
263+
"""
264+
res = assembly_pressure_drop(
265+
a,
266+
current_a=current_a,
267+
temperature_k=temperature_k,
268+
stoich_ratio=stoich_ratio,
269+
)
270+
q_cell = stoichiometric_water_flow_m3_s(
271+
current_a=current_a,
272+
stoich_ratio=stoich_ratio,
273+
temperature_k=temperature_k,
274+
)
275+
return a.n_cells * pump_power_w(res.dp_pa, q_cell, eta_pump)
276+
277+
176278
# ---------------- JSON Save/Load ---------------- #
177279

178280

src/streamlit_app.py

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
from src import units as U
2424
from src.assembly import (
2525
StackAssembly,
26+
active_dimensions_m,
27+
assembly_pressure_drop,
28+
assembly_pump_power_w,
2629
bpp_outer_dimensions_m,
2730
bpp_resistance_ohm_m2,
2831
from_json,
@@ -146,6 +149,23 @@
146149
value=100.0,
147150
step=1.0,
148151
)
152+
aspect_ratio = st.sidebar.slider(
153+
"Aspect ratio (width / height)",
154+
min_value=0.25,
155+
max_value=4.0,
156+
value=1.0,
157+
step=0.05,
158+
help=(
159+
"Shape of the active area. 1.0 = square (v0.4 default). "
160+
"0.5 = tall, 2.0 = wide. Area stays constant; only w·h ratio changes."
161+
),
162+
)
163+
_aw_m = (U.cm2_to_m2(active_area_cm2) * aspect_ratio) ** 0.5
164+
_ah_m = (U.cm2_to_m2(active_area_cm2) / aspect_ratio) ** 0.5
165+
st.sidebar.caption(
166+
f"→ active {_aw_m * 1000:.1f} × {_ah_m * 1000:.1f} mm "
167+
f"(area {active_area_cm2:.0f} cm² unchanged)"
168+
)
149169

150170
# ---------------- Sidebar: Thermal ---------------- #
151171
st.sidebar.header("Thermal management")
@@ -633,6 +653,7 @@
633653
current_collector=cc_sel,
634654
gasket=gk_sel,
635655
tie_rod=tr_sel,
656+
aspect_ratio=float(aspect_ratio),
636657
)
637658

638659
if uploaded is not None:
@@ -649,11 +670,12 @@
649670
fig_cs = draw_layer_cross_section(assembly, max_visible_cells=6)
650671
st.plotly_chart(fig_cs, use_container_width=True)
651672

652-
fig_top = draw_bpp_top_view(bpp_sel, area_si, gk_sel)
673+
fig_top = draw_bpp_top_view(bpp_sel, area_si, gk_sel, aspect_ratio=float(aspect_ratio))
653674
st.plotly_chart(fig_top, use_container_width=True)
654675

655676
st.markdown("### Stack stats")
656-
bpp_w_m, _ = bpp_outer_dimensions_m(assembly)
677+
bpp_w_m, bpp_h_m = bpp_outer_dimensions_m(assembly)
678+
aw_m, ah_m = active_dimensions_m(assembly)
657679
stack_h_mm = total_stack_height_m(assembly) * 1000.0
658680
stack_m_kg = total_stack_mass_kg(assembly)
659681
r_bpp = bpp_resistance_ohm_m2(assembly)
@@ -662,14 +684,85 @@
662684
k1, k2, k3, k4 = st.columns(4)
663685
k1.metric("Stack height", f"{stack_h_mm:.1f} mm")
664686
k2.metric("Stack mass (approx.)", f"{stack_m_kg:.2f} kg")
665-
k3.metric("BPP outer edge", f"{bpp_w_m * 1000.0:.1f} mm")
687+
k3.metric("BPP outer", f"{bpp_w_m * 1000.0:.1f} × {bpp_h_m * 1000.0:.1f} mm")
666688
k4.metric("Open-area ratio", f"{oar * 100:.0f} %")
667689

668690
st.caption(
669-
f"Effective r_bpp from assembly: {r_bpp * 1e4:.3f} mΩ·cm² "
691+
f"Active area: {aw_m * 1000:.1f} × {ah_m * 1000:.1f} mm "
692+
f"(aspect ratio {aspect_ratio:.2f}). "
693+
f"Effective r_bpp: {r_bpp * 1e4:.3f} mΩ·cm² "
670694
f"(ρ·t of {assembly.bipolar_plate_spec().material}) — active in Polarization tab."
671695
)
672696

697+
st.divider()
698+
st.markdown("### Flow field — pressure drop & pump power")
699+
st.caption(
700+
"Laminar ΔP via Darcy-Weisbach with Shah & London (1978) f·Re correlation "
701+
"for rectangular channels. Volumetric flow from stoichiometry "
702+
"(λ · I / (2·F) · M_H₂O / ρ)."
703+
)
704+
705+
fc1, fc2 = st.columns(2)
706+
with fc1:
707+
stoich_lambda = st.slider(
708+
"Stoichiometric ratio λ",
709+
min_value=1.0,
710+
max_value=200.0,
711+
value=50.0,
712+
step=1.0,
713+
help="Water feed = λ · stoichiometric minimum. Typical PEM-EC: 10–100.",
714+
)
715+
with fc2:
716+
eta_pump = st.slider(
717+
"Pump efficiency η_pump",
718+
min_value=0.10,
719+
max_value=0.90,
720+
value=0.50,
721+
step=0.05,
722+
help="Commercial membrane/centrifugal pumps for EC service: 0.3–0.7.",
723+
)
724+
725+
current_per_cell_a = j_op_si * area_si # I = j · A (cells in series → same I)
726+
try:
727+
dp_result = assembly_pressure_drop(
728+
assembly,
729+
current_a=current_per_cell_a,
730+
temperature_k=t_kelvin,
731+
stoich_ratio=stoich_lambda,
732+
)
733+
pump_total_w = assembly_pump_power_w(
734+
assembly,
735+
current_a=current_per_cell_a,
736+
temperature_k=t_kelvin,
737+
stoich_ratio=stoich_lambda,
738+
eta_pump=eta_pump,
739+
)
740+
pump_frac = (
741+
pump_total_w / (stack_p["p_electric_kw"] * 1000.0)
742+
if stack_p["p_electric_kw"] > 0
743+
else 0.0
744+
)
745+
746+
f1, f2, f3, f4 = st.columns(4)
747+
f1.metric("ΔP per cell", f"{dp_result.dp_pa / 1000:.2f} kPa")
748+
f2.metric("Flow velocity", f"{dp_result.velocity_m_s * 100:.1f} cm/s")
749+
f3.metric("Reynolds", f"{dp_result.reynolds:.0f}")
750+
f4.metric("Pump power (stack)", f"{pump_total_w:.1f} W")
751+
752+
st.caption(
753+
f"{dp_result.n_channels} channel(s), "
754+
f"path length {dp_result.path_length_m * 1000:.1f} mm, "
755+
f"D_h = {dp_result.hydraulic_diameter_m * 1e6:.0f} µm, "
756+
f"f = {dp_result.friction_factor:.3f}. "
757+
f"Pump parasitic: {pump_frac * 100:.2f} % of stack electrical power."
758+
)
759+
except ValueError as err:
760+
st.warning(
761+
f"Pressure-drop model out of range: {err}. "
762+
"v0.5 covers laminar only (Re < 2000). Try lower current, larger channels, "
763+
"or a parallel flow pattern."
764+
)
765+
673766
st.divider()
674767
st.markdown("**Save config**")
675768
import json as _json

0 commit comments

Comments
 (0)