From f160e1fcc081e68d6e6491801d8ec00cfb3561d8 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 21:27:43 +0200 Subject: [PATCH 01/14] test(pp_interop): parity with analyze on the canonical voltage-drop fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors test_compute_voltage_drop_uses_phase_voltage_reference (test_analyze.py) but routes the same hand-crafted fixture through pandapower's runpp and asserts both produce 220 V / 4.35 %. Same fixture: 26 m of 1 mm² cable, single-phase 10 A. nopywer's RHO_COPPER = 1/26 makes R = 1.0 Ω exactly, so ΔV = R · I = 10 V. Key insight: nopywer's tree walk computes I = P / V0 / PF once at nominal voltage and uses that current everywhere — that's exactly pandapower's `const_i_p_percent=100` constant-current load model. Under matched load assumptions (and PF=1, no cable X) the two tools land on the same answer to within 0.05 V / 0.02 % vdrop. This is the conversion-correctness test. Any future divergence on real fixtures (test_pp_interop_comparison.py) is then known to come from physics nopywer simplifies away — load feedback, reactive power, line reactance — not from a translation bug. --- tests/test_pp_interop_parity.py | 129 ++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/test_pp_interop_parity.py diff --git a/tests/test_pp_interop_parity.py b/tests/test_pp_interop_parity.py new file mode 100644 index 0000000..6a8fbcc --- /dev/null +++ b/tests/test_pp_interop_parity.py @@ -0,0 +1,129 @@ +"""Parity tests: nopywer's tree walk and pandapower's AC power flow agree +exactly when the modelling assumptions are matched. + +The point of these tests is to prove the **conversion is faithful** — +when both tools see the same physics, they produce the same numbers. +Any divergence on real fixtures (see tests/test_pp_interop_comparison.py) +is then known to come from physics nopywer simplifies away (load +feedback, reactive power, line reactance), not from a translation bug. + +Mirror of `test_compute_voltage_drop_uses_phase_voltage_reference` in +test_analyze.py — same hand-crafted 26 m / 1 mm² / 10 A fixture, same +expected 220 V / 4.35 % answer. +""" + +import math + +import pytest + +pp = pytest.importorskip("pandapower") + +from nopywer.analyze import _compute_voltage_drop +from nopywer.constants import V0 +from nopywer.models import Cable, PowerGrid, PowerNode +from nopywer.pp_interop import config + + +def test_voltage_drop_matches_pandapower_with_constant_current_load(): + """nopywer == pandapower when the load is modelled as constant-current. + + nopywer's tree walk computes I = P / V0 / PF **once** at nominal + voltage and uses that current to derive ΔV = R·I. That's exactly the + constant-current load model in pandapower (`const_i_percent=100`): + the load draws the rated current regardless of its actual local + voltage, so there's no nonlinear feedback to iterate over. + + Same fixture as test_compute_voltage_drop_uses_phase_voltage_reference: + 26 m of 1 mm² cable, single-phase load drawing 10 A. + With RHO_COPPER = 1/26, R = (1/26) * 26 / 1 = 1.0 Ω exactly. + ΔV = R * I = 1 * 10 = 10 V exactly. + V_load = 230 - 10 = 220 V, vdrop = 100 * 10/230 = 4.35 %. + """ + # --- nopywer side: bypass `analyze`, hand-build the tree state + # and call `_compute_voltage_drop` directly, exactly as the + # original test does. --- + generator = PowerNode( + name="generator", + lon=0.0, + lat=0.0, + is_generator=True, + children={"load_a": "c1"}, + voltage=V0, + ) + load = PowerNode( + name="load_a", + lon=0.0, + lat=0.0, + parent="generator", + cable_to_parent="c1", + ) + cable = Cable( + id="c1", + length_m=26.0, + area_mm2=1.0, + from_node="generator", + to_node="load_a", + current_per_phase=[10.0], + ) + grid = PowerGrid( + nodes={"generator": generator, "load_a": load}, + cables={"c1": cable}, + ) + _compute_voltage_drop(grid) + + # Sanity: the nopywer numbers are the ones the original test pins. + assert cable.vdrop_volts == 10.0 + assert load.voltage == 220.0 + assert load.vdrop_percent == 4.35 + + # --- pandapower side: build the same scenario from scratch using + # the same conventions our converter uses (vn_kv L-L equivalent + # of 230 V P-N), and a constant-current load drawing 10 A per + # phase. --- + vn_kv_ll = config.VN_KV_LL # 0.3984 kV — exactly 230 V P-N + + net = pp.create_empty_network(sn_mva=1.0, f_hz=50.0) + b_gen = pp.create_bus(net, vn_kv=vn_kv_ll, name="generator") + b_load = pp.create_bus(net, vn_kv=vn_kv_ll, name="load_a") + pp.create_ext_grid(net, bus=b_gen, vm_pu=1.0) + + # R = 1 Ω over 26 m means r_per_km = 1 / 0.026 ≈ 38.46. + # Equivalently RHO_COPPER * 1000 / area_mm2 = (1/26)*1000/1 = same. + pp.create_line_from_parameters( + net, + from_bus=b_gen, + to_bus=b_load, + length_km=0.026, + r_ohm_per_km=(1.0 / 26.0) * 1000.0 / 1.0, + # Negligible (1 nΩ/km) but non-zero — pandapower's solver init + # divides by x to compute susceptance and chokes on x = 0. + x_ohm_per_km=1e-9, + c_nf_per_km=0.0, + max_i_ka=0.016, + ) + + # Constant-current load drawing 10 A per phase line. + # 3-phase line current: I = P / (sqrt(3) * V_LL * PF) + # ⇒ P = I * sqrt(3) * V_LL = 10 * sqrt(3) * 398.4 ≈ 6900 W. + p_mw = 10.0 * math.sqrt(3) * vn_kv_ll * 1000.0 / 1e6 + pp.create_load( + net, + bus=b_load, + p_mw=p_mw, + q_mvar=0.0, + const_i_p_percent=100.0, + const_z_p_percent=0.0, + ) + + pp.runpp(net) + + pn_v_at_nominal = vn_kv_ll * 1000.0 / math.sqrt(3) # ≈ 230 V + vm_pu = float(net.res_bus.at[b_load, "vm_pu"]) + pp_v_pn = vm_pu * pn_v_at_nominal + pp_vdrop_percent = 100.0 * (V0 - pp_v_pn) / V0 + + # --- compare: pandapower lands on 220.016 V / 4.341 % — within + # 0.05 V / 0.02 % of nopywer's analytical 220 / 4.35. + # Any larger gap would indicate a conversion bug. --- + assert pp_v_pn == pytest.approx(220.0, abs=0.05) + assert pp_vdrop_percent == pytest.approx(4.35, abs=0.02) From 488f99b0862028a8ce4765382ba16e3f004e2db8 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 21:30:23 +0200 Subject: [PATCH 02/14] test(pp_interop): side-by-side comparison vs analyze on each fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the tree-walk-vs-AC divergence on every existing analyze fixture so future changes to either side are visible. Three fixtures cover the three "shapes" of disagreement: 1. analyze_named_generator (small balanced load) — both tools agree to within rounding (<0.1 V / 0.05 %). The conversion is faithful in the regime where physics simplifications don't bite. 2. analyze_input (unbalanced single-phase loads) — nopywer's `max(current_per_phase)` rule pushes the tree walk's reported drop ABOVE pandapower's balanced runpp. At load_b: tree walk says 8.13 %, AC says 3.12 % — tree walk overstates by ~5 pp because it's conservative-by-design, while our converter currently collapses single-phase loads to balanced (runpp_3ph is opportunity §3 in 06_extended_opportunities.md). 3. input_nodes (the 2025 event, optimised) — constant-power feedback at low local voltage means more current, more drop. Tree walk computes I at nominal V0 once and never iterates, so it UNDERSTATES drop by ~12 pp at the worst node. This is the headline finding from 07_optimiser_validation_findings.md, captured here as a regression check. The two effects are physically real and pull in opposite directions. Tests assert direction-of-disagreement plus pinned numbers to ±2 pp, so any improvement to either tool's modelling will require a conscious update to these expectations. --- tests/test_pp_interop_comparison.py | 150 ++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 tests/test_pp_interop_comparison.py diff --git a/tests/test_pp_interop_comparison.py b/tests/test_pp_interop_comparison.py new file mode 100644 index 0000000..9b2439c --- /dev/null +++ b/tests/test_pp_interop_comparison.py @@ -0,0 +1,150 @@ +"""Side-by-side comparison: nopywer's tree walk vs pandapower's AC flow. + +These tests pin the divergence between the two tools on each existing +analyze fixture, so future changes to either side get caught. + +The divergence is **not one-directional** — three different shapes show +up depending on the fixture: + +1. Low drop, balanced load + → tree walk and AC agree to within rounding. + +2. Unbalanced single-phase loads + → nopywer's `max(current_per_phase)` rule is conservative and + **over-states** voltage drop relative to pandapower's balanced + runpp (which our converter currently uses — runpp_3ph is + opportunity §3 in 06_extended_opportunities.md). + +3. High drop, balanced load + → constant-power feedback at low local voltage means more current, + more drop. The tree walk computes I at nominal V0 once and never + iterates, so it **under-states** voltage drop. + +Numbers come from running compare_with_tree_walk() against each +fixture; pinned with ±0.5 V / ±0.5 % tolerance to absorb minor +numerical noise but tight enough to catch real changes. +""" + +import json +from pathlib import Path + +import pytest + +pytest.importorskip("pandapower") + +from nopywer.io import load_geojson +from nopywer.models import PowerGrid +from nopywer.optimize import optimize_layout +from nopywer.pp_interop import compare_with_tree_walk + +FIXTURES = Path(__file__).parent / "fixtures" + + +def test_named_generator_fixture_low_drop_parity(): + """Small balanced load — tree walk and AC agree to within rounding. + + 1 generator + 1 unphased 1 kW load through 10 m cable. Because the + load is unphased, io.load_geojson splits it evenly across phases + (333 W per phase, balanced). At ~1.6 A per phase through 20 m of + 2.5 mm² (~ 0.31 Ω), drop is well under 1 V — far too small for + constant-power feedback to matter. Both tools land within 0.1 V. + """ + nodes, cables = load_geojson(FIXTURES / "analyze_named_generator.geojson") + grid = PowerGrid(nodes=nodes, cables=cables) + + diff = compare_with_tree_walk(grid) + assert diff.converged + + tw_v, ac_v, delta_v = diff.bus_voltage_v["load a"] + assert tw_v == pytest.approx(229.5, abs=0.1) + assert ac_v == pytest.approx(229.6, abs=0.1) + assert abs(delta_v) < 0.1 # rounding-level agreement + + tw_pct, ac_pct, delta_pct = diff.bus_vdrop_percent["load a"] + assert tw_pct == pytest.approx(0.22, abs=0.05) + assert ac_pct == pytest.approx(0.19, abs=0.05) + assert abs(delta_pct) < 0.1 + + tw_i, ac_i, delta_i = diff.line_current_a["cable_0"] + assert tw_i == pytest.approx(1.61, abs=0.05) + assert ac_i == pytest.approx(1.61, abs=0.05) + + +def test_analyze_input_fixture_unbalanced_max_phase_overstates_drop(): + """Unbalanced single-phase loads — nopywer's max-phase rule pushes + the tree walk's reported drop **above** pandapower's balanced flow. + + Fixture: + generator → cable_0 (20 m) → load_a (3 kW on phase 1) + → cable_1 (22 m) → load_b (6 kW on phase 2) + + nopywer cumulates power per phase: cable_0 sees [3 kW, 6 kW, 0], + so current_per_phase = [14.5, 29.0, 0] A. ΔV is then computed + against `max(current_per_phase) = 29.0 A` — the worst-phase + estimate, conservative by design. + + Our `to_pandapower` collapses single-phase loads to balanced + 3-phase loads (runpp_3ph is opportunity §3). So pandapower sees + 9 kW total, balanced, with per-phase line current ~14.5 A. + Result: pandapower reports a smaller drop than the tree walk. + + This is **not a bug in either tool** — it's two different physical + models. The test pins the gap so future changes to either side + are visible. + """ + nodes, cables = load_geojson(FIXTURES / "analyze_input.geojson") + grid = PowerGrid(nodes=nodes, cables=cables) + + diff = compare_with_tree_walk(grid) + assert diff.converged + + # Worst-stressed node: load_b. Tree walk says 8.1 %; AC says 3.1 %. + tw_pct, ac_pct, delta_pct = diff.bus_vdrop_percent["load b"] + assert tw_pct == pytest.approx(8.13, abs=0.1) + assert ac_pct == pytest.approx(3.12, abs=0.1) + # Direction: tree walk overstates by ~5 pp. + assert tw_pct > ac_pct + assert (tw_pct - ac_pct) == pytest.approx(5.0, abs=0.5) + + # Cable currents: tree walk's max-phase rule reports the phase 2 + # current (29 A), AC reports the balanced equivalent (~10–15 A). + for cable_id in ("cable_0", "cable_1"): + tw_i, ac_i, _ = diff.line_current_a[cable_id] + assert tw_i > ac_i, f"{cable_id}: expected tree walk I > AC I" + + +def test_optimised_input_nodes_fixture_high_drop_understates_drop(): + """High-stress balanced case — constant-power feedback makes + pandapower's AC drop **larger** than the tree walk's. + + This is the headline finding from + research/pandapower/07_optimiser_validation_findings.md, captured + here as a regression check: at the worst-stressed node `glitch`, + the tree walk reports ~27 % drop and AC reports ~39 %. + + If anyone "fixes" the tree walk to be more accurate (e.g. by + iterating to fixed-point), this test goes red and forces the + 07-doc finding to be updated. + """ + with open(FIXTURES / "input_nodes.geojson") as f: + nodes_geojson = json.load(f) + nodes, _ = load_geojson(nodes_geojson) + grid = optimize_layout(PowerGrid(nodes=nodes, cables={})) + + diff = compare_with_tree_walk(grid) + assert diff.converged + + # `glitch` was the worst node in the 07 findings doc. + assert "glitch" in diff.bus_vdrop_percent, ( + "fixture changed: 'glitch' no longer in optimised layout — " + "update test and 07_optimiser_validation_findings.md" + ) + tw_pct, ac_pct, delta_pct = diff.bus_vdrop_percent["glitch"] + + # Pin both numbers loosely. Optimisation has some non-determinism + # via floats but the answer is stable to ~1 pp. + assert tw_pct == pytest.approx(27.0, abs=2.0) + assert ac_pct == pytest.approx(39.0, abs=2.0) + # Direction: AC overstates the tree walk by ~12 pp at this node. + assert ac_pct > tw_pct, "expected AC drop > tree-walk drop at high stress" + assert (ac_pct - tw_pct) == pytest.approx(12.0, abs=2.0) From beb33936304557ac3caf969de6fd823844973806 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 21:45:26 +0200 Subject: [PATCH 03/14] docs(research): why tree walk and pandapower AC flow disagree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Power-systems theory walkthrough of the divergence captured by tests/test_pp_interop_comparison.py. Covers: - The 230 V P-N vs 400 V L-L convention (and where it bites in interop) - The ZIP load model (constant-Z, constant-I, constant-P) - Why nopywer's tree walk is implicitly a constant-current solver - Why pandapower's runpp is constant-power and iterates to equilibrium Then the three mechanisms that make the two tools disagree, in detail: 1. Small balanced load → both shortcuts inactive → tools agree to rounding (the conversion-correctness check). 2. Unbalanced single-phase → nopywer's max(I_per_phase) rule over-states drop while pandapower's balanced collapse under-states it. Neither is right; truth needs runpp_3ph. 3. High-stress balanced → nopywer's constant-I shortcut means it misses the V-drops-→-more-current-→-more-drops feedback that AC iterates to equilibrium. Tree walk under-states by ~12 pp. Closes the loop on the comparison tests by explaining WHY each direction shows up. --- .../08_why_tree_walk_and_ac_disagree.md | 384 ++++++++++++++++++ research/pandapower/README.md | 4 + 2 files changed, 388 insertions(+) create mode 100644 research/pandapower/08_why_tree_walk_and_ac_disagree.md diff --git a/research/pandapower/08_why_tree_walk_and_ac_disagree.md b/research/pandapower/08_why_tree_walk_and_ac_disagree.md new file mode 100644 index 0000000..271718c --- /dev/null +++ b/research/pandapower/08_why_tree_walk_and_ac_disagree.md @@ -0,0 +1,384 @@ +# Why nopywer's tree walk and pandapower's AC flow disagree + +A walkthrough of the physics behind the divergence captured in +`tests/test_pp_interop_comparison.py` (and quantified in +[`07_optimiser_validation_findings.md`](./07_optimiser_validation_findings.md)). + +The headline finding from the comparison tests: + +| Fixture | nopywer | pandapower | direction | +|---|---:|---:|---| +| `analyze_named_generator` (small balanced) | 0.22 % | 0.19 % | parity | +| `analyze_input` (unbalanced single-phase) | 8.13 % at load_b | 3.12 % | nopywer **over-states** | +| `input_nodes` optimised (high-stress balanced) | 26.9 % at glitch | 39.1 % | nopywer **under-states** | + +The disagreement is **not one-directional**. Two different +simplifications, one in each tool, pull in opposite directions. To +see why, a small amount of power-systems theory is needed. + +## Power theory primer — just enough to read the rest + +Festival LV is **400 V three-phase, 50 Hz, four-wire**: three phase +conductors L1 / L2 / L3 plus a neutral N. + +``` + L1 ────┐ + │ + L2 ────┼──── 400 V line-to-line (V_LL) + │ + L3 ────┘ + │ + │ 230 V line-to-neutral (V_PN) + │ ( = V_LL / √3 ) + │ + N ────┘ +``` + +One physical wire bundle, two conventions: + +- **Single-phase loads** (lighting, sound, sockets) connect L1–N + (or L2–N or L3–N) and see 230 V. nopywer's `V0 = 230` is this. +- **Three-phase loads** (motors, big distros) connect across all + three phases and see 400 V between any two of them. pandapower's + bus voltage `vn_kv = 0.4` is this. + +The √3 ratio between them is where almost every interop bug hides. + +Three-phase real power for a balanced load: + +``` + P = √3 · V_LL · I · cos φ + = 3 · V_PN · I · cos φ +``` + +Two equivalent forms. `cos φ` is the **power factor** — 1 for a pure +resistor (lights), ~0.7 for inductive loads (motors, transformers), +even lower for switch-mode PSUs. nopywer hardcodes `PF = 0.9`. + +Voltage drop across a cable, to first order, is just Ohm's law: + +``` + ΔV = I · R (resistive cable, X ≈ 0) +``` + +That's the equation nopywer's tree walk uses. The full physics has +an `I · X` term too (reactance), and `I` is itself complex +(real + imaginary), so the actual scalar drop is `|I| · |Z|` where +`Z = R + jX`. Pandapower solves the full thing. + +## Three load models — the ZIP + +A load doesn't have a single "current". The current it draws depends +on the voltage it's currently seeing. Three idealised behaviours: + +``` + Constant-Z (impedance): I = V/Z like a resistor — V drops, I drops linearly + Constant-I (current): I = I_rated like a current source — I never moves + Constant-P (power): I = P/V like a regulated PSU — V drops, I RISES +``` + +Real loads are mostly P (electronics with switch-mode PSUs auto- +regulate to maintain power), partly I, partly Z. The ZIP model +mixes all three. Pandapower's `pp.create_load` defaults to +**constant-P**. + +**nopywer's tree walk is implicitly a constant-current model.** It +computes `I = P / V0 / PF` once at nominal voltage and uses that +current to derive `ΔV = R · I`. The current never updates when the +local voltage drops. This is one of the two key simplifications. + +## What the tree walk actually does + +``` +Step 1: cumulate power (leaves → root) + Each cable's "cumulative power" is the sum of all loads + downstream of it, kept as a 3-element [P_L1, P_L2, P_L3] + array. + +Step 2: derive current per phase + I_per_phase = cum_power / V0 / PF + (uses NOMINAL V0, not the local V — this is the + constant-I shortcut) + +Step 3: voltage drop (root → leaves) + For each cable: + ΔV_cable = R_cable · max(I_per_phase) ← takes the + WORST phase + V_child = V_parent − ΔV_cable +``` + +Two shortcuts here. + +**Shortcut A — `V0` instead of local V.** Computes current as if +every load saw a perfect 230 V regardless of what's actually +arriving. For loads near the generator with small drops, fine. For +loads downstream where the local voltage has already dropped to +(say) 200 V, the real load draws *more* current to maintain its +rated power, but the tree walk doesn't know. + +**Shortcut B — `max(I_per_phase)` for the whole cable.** Even if +only L2 is heavily loaded, nopywer applies the L2 drop to *everyone* +on the cable. This is conservative — overestimates how much voltage +has been lost — but treats the four-wire cable as if all three +phases drop together. In a real four-wire system the lightly-loaded +phases don't drop nearly as much, and downstream loads on those +phases see almost no impact. + +Both shortcuts make the tree walk **a single pass** with no +iteration. That's what makes it microsecond-fast — and what creates +the disagreement with AC. + +## What pandapower's `runpp` does + +``` +Build admittance matrix Y for the full network +Guess voltages V₀ = 1.0 p.u. everywhere +Newton-Raphson loop: + given V, compute power injections S = V · (Y·V)* + residual = S − S_specified_by_loads_and_generators + Δ = J⁻¹ · residual (J = Jacobian) + V ← V + Δ + iterate until |Δ| < tol +``` + +It iterates to a self-consistent equilibrium where every load is +drawing **its specified P at its actual local V**. No shortcuts. The +constant-P load model means current rises as V falls, the falling V +makes more current, more current makes more drop — and the NR +iteration finds the fixed point. + +For balanced 3-phase, `runpp` does this in the positive-sequence +per-phase frame — one phase per bus, the other two assumed +symmetric. That's faster than the full three-phase asymmetric solve +(`runpp_3ph`) but loses the imbalance information. **Our +`to_pandapower` collapses unbalanced single-phase loads to balanced** +— an L1-only 3 kW load becomes 1 kW on each of L1, L2, L3. This is +the matching simplification on the pandapower side. + +## Mechanism 1 — small balanced load: tree walk and AC agree + +`analyze_named_generator.geojson`: 1 kW unphased load, 10 m of +2.5 mm² cable. + +In numbers: +- power split balanced → `power_per_phase = [333, 333, 333]` W +- per-phase current: `333 / 230 / 0.9 ≈ 1.61 A` +- cable R: `(1/26) × 20 / 2.5 ≈ 0.31 Ω` (length includes 10 m + `EXTRA_CABLE_LENGTH_M` slack) +- ΔV: `0.31 × 1.61 ≈ 0.5 V` + +Both shortcuts are inactive: + +- **Constant-current vs constant-power doesn't matter at 0.5 V drop.** + V at the load is 229.5 V vs nominal 230 V. The constant-P load + wants 333 W, so its current is `333 / 229.5 / 0.9 ≈ 1.611 A` vs + the tree walk's `333 / 230 / 0.9 ≈ 1.610 A`. Difference at the 4th + decimal. Negligible. +- **Max-phase doesn't matter for balanced loads.** All three phases + carry the same current, so `max(I_per_phase) == mean(I_per_phase)`. + The tree walk's "worst phase" rule degenerates to the right answer. + +Result: tree walk says 0.22 %, AC says 0.19 %, Δ = 0.03 %. Both +tools agree because **the regime is too benign to expose any of +the simplifications**. + +This test is the **conversion-correctness check**. A real bug in +`to_pandapower` (wrong voltage convention, wrong R formula, wrong +unit anywhere) would show up here. It passes — so we know the +conversion is faithful, and any divergence elsewhere is real +physics. + +## Mechanism 2 — unbalanced single-phase: tree walk over-states + +`analyze_input.geojson`: + +``` +generator ── cable_0 (20 m, 2.5 mm²) ── load_a (3 kW on L1) + ── cable_1 (22 m) ── load_b (6 kW on L2) +``` + +**What nopywer sees** — power_per_phase cumulates as a 3-vector, +keeping the phase assignment: + +``` +load_a: [3000, 0, 0] +load_b: [ 0, 6000, 0] +cable_1: [ 0, 6000, 0] → I = [ 0, 28.99, 0] A → max = 28.99 A +cable_0: [3000, 6000, 0] → I = [14.49, 28.99, 0] A → max = 28.99 A + +ΔV cable_0: R × max(I) = 0.308 × 28.99 ≈ 8.93 V +ΔV cable_1: 0.339 × 28.99 ≈ 9.83 V + +V at load_b = 230 − 8.93 − 9.83 = 211.24 V → drop 8.13 % +``` + +In nopywer's world, load_b is at 211 V — way past the 5 % +NF C 15-100 limit. Conservative by design. + +**What pandapower sees** — single-phase loads collapsed to balanced: + +``` +9 kW total, balanced → 3 kW on each of L1, L2, L3 + +cable_0: 9 kW total → per-phase line current = 9000 / (√3 × 400 × 0.9) ≈ 14.4 A +cable_1: 6 kW → ~ 9.6 A + ↑ + (NB: √3·V_LL not V_PN because pandapower works in L-L) + +ΔV per phase at cable_0: 0.308 × 14.4 ≈ 4.4 V (per phase P-N) +V at load_b ≈ 230 − 4.4 − 3.3 ≈ 222.3 V → drop 3.12 % +``` + +Pandapower says load_b is at 222 V — comfortably under the 5 % +limit. + +**Why so different?** Two opposing effects: + +1. nopywer's `max(I_per_phase)` **overstates** the drop on the + lightly-loaded phases. In the real four-wire cable, L2 carries + 29 A and drops a lot, L1 carries 14 A and drops half as much, + L3 carries nothing and drops nothing. nopywer paints all three + with the L2 brush. + +2. pandapower's balanced collapse **understates** the drop on the + heavily-loaded phase. By averaging the 6 kW load across three + phases, the L2 wire only "sees" 1/3 of its real burden. + +**Neither model is fully right.** The truth is in between, and +you'd need `runpp_3ph` with `asymmetric_load` to get it. For now: + +- nopywer's number is the **safe** one for sizing decisions (it's + conservative on the worst phase). +- pandapower's number is a useful sanity check for "will the + average node be near nominal", but understates risk on the + loaded phase. + +This is the regime where nopywer's festival heuristic actually +beats pandapower's standard balanced flow, *if* you accept the +conservatism. + +## Mechanism 3 — high-drop balanced: tree walk under-states + +The optimised 2025 fixture: 51 nodes, the worst-stressed node +`glitch` is many cables deep from the generator. Loads are mixed +but the optimiser produces a layout where each cable carries near +its tier capacity. + +**What nopywer sees** — cable currents computed at nominal V0 +(= 230 V) **once**. ΔV stacks linearly from generator to leaf. +Reports 26.9 % drop at glitch — meaning V_glitch = `230 × 0.731 = +168 V`. + +**What pandapower sees** — same topology, same cable resistances, +but it **iterates**: + +``` +Iteration 0: assume V = 1.0 p.u. everywhere. Compute currents. + Compute drops. New V at glitch ≈ 0.73 p.u. (≈ tree walk). + +Iteration 1: at V = 0.73 p.u., the constant-P load at glitch wants + 1/0.73 = 1.37× more current to maintain its rated P. + More current → more drop everywhere upstream. + New V at glitch drops to ≈ 0.65 p.u. + +Iteration 2: at V = 0.65 p.u., even more current, even more drop. + V drops to ≈ 0.62 p.u. + +...converges around V = 0.609 p.u. = 140 V → drop 39 %. +``` + +Pandapower says glitch is at 140 V — light bulbs flicker, motors +stall, switch-mode PSUs trip. The tree walk reported 168 V — +already alarming but understating the actual situation. + +**This is the load-feedback loop:** + +``` + V at load drops below nominal + ↓ + Constant-P load: P = V · I = const + ↓ + I must rise to keep P + ↓ + More I × same R = more upstream drop + ↓ + V at load drops further ──────┐ + ↑ │ + └──────────────────┘ +``` + +The tree walk is blind to this because it computes I once and +stops. It reports the *first iteration* answer, which is always +optimistic in the high-drop regime. + +The conservative `RHO_COPPER = 1/26` (≈ 2.2× textbook copper) +doesn't compensate, because the missing physics is not the +resistance value — it's the missing iteration. + +## Why the two errors pull opposite ways + +``` + tree walk pandapower physical truth + (balanced + collapse) + ──────── ────────── ────────────── +unbalanced +single-phase HIGH drop LOW drop somewhere between + (max-phase (averages + rule) load) + +high-stress +balanced LOW drop HIGH drop pandapower's right + (constant-I (constant-P (loads really do + shortcut) iteration) pull more current + at low V) +``` + +The two simplifications happen at different layers: + +- **Max-phase** is a conservative *phase-frame* approximation — + captures the safety-of-sizing concern but ignores that the + four-wire cable doesn't drop uniformly across phases. +- **Constant-I** is an optimistic *load-model* approximation — + captures the simple-Ohm's-law concern but ignores that real + loads regulate themselves in response to voltage. + +For a small balanced load (mechanism 1), neither bites and they +agree. For unbalanced single-phase loads (mechanism 2), max-phase +dominates and tree walk over-states. For high-stress balanced +loads (mechanism 3), constant-I dominates and tree walk +under-states. + +## Practical takeaway + +The tree walk is the **right tool** when: + +- many small single-phase loads (typical at the small-distro level), +- and you want a **conservative** cable-sizing estimate. + +It's the **wrong tool** when: + +- a long radial trunk feeds heavy balanced loads (sound stage, + kitchen tent, ice plant), +- and the drop is large enough that constant-power feedback matters. + +The 2025 fixture is the second case — and the AC validation is +what reveals it. + +## The right long-term fix + +`runpp_3ph` with `asymmetric_load` — opportunity §3 in +[`06_extended_opportunities.md`](./06_extended_opportunities.md). +That solver: + +- Models unbalanced single-phase loads as their actual single-phase + selves on L1/L2/L3 with a separate neutral conductor. +- Iterates the constant-P feedback so high-drop nodes converge to + the true equilibrium V. + +Both errors would go away, in both directions, simultaneously. The +conversion already exists; it's the load-creation step that needs +an asymmetric variant. The cost is having to populate +zero-sequence cable parameters (typical defaults documented in +config), and `runpp_3ph` is more sensitive to convergence than +balanced `runpp`. diff --git a/research/pandapower/README.md b/research/pandapower/README.md index 1c6fa29..d673f39 100644 --- a/research/pandapower/README.md +++ b/research/pandapower/README.md @@ -32,6 +32,10 @@ Date of research: April 2026. Pandapower latest at time of writing: **3.4.0** what was built for opportunity §1 (AC validation of the optimiser) and what running it on the 2025 fixture revealed: tree walk under-reports voltage drop by up to 12 percentage points at high-stress nodes. +8. [`08_why_tree_walk_and_ac_disagree.md`](08_why_tree_walk_and_ac_disagree.md) — + power-systems theory walkthrough of why nopywer and pandapower + disagree, and why the disagreement runs in opposite directions on + different fixtures (max-phase rule vs constant-power feedback). ## TL;DR From d6cd989cb09ac5956d10ba8426f7518c6dd70514 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 21:50:56 +0200 Subject: [PATCH 04/14] docs(test): explain what each pp_interop test is comparing and why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds module-level docs and per-test docstrings across the three pp_interop test files. Each test now spells out: - "What this is comparing" — the two sides being lined up - "What this is testing" — which property is being asserted - "Real-world scenario" — the festival situation the test models The three files now read as a deliberate three-layer story: test_pp_interop.py Smoke / conversion / API tests on a trivial 1-cable grid. Festival mapping: any layout would touch this code, so any silent unit/convention bug would show here first. test_pp_interop_parity.py Mirrors the canonical analyze unit test through pandapower with matched assumptions (constant-current load, no X). Proves the conversion is faithful — any divergence elsewhere is physics, not a bug. test_pp_interop_comparison.py Three tests, three festival scenarios, three "shapes" of tree-walk-vs-AC disagreement: 1. small distro near the generator → both tools agree 2. unbalanced single-phase distro → nopywer over-states 3. long radial trunk to heavy load → nopywer under-states Tests unchanged; assertions identical. Pure documentation. --- tests/test_pp_interop.py | 325 +++++++++++++++++++++++----- tests/test_pp_interop_comparison.py | 224 ++++++++++++++----- tests/test_pp_interop_parity.py | 125 +++++++++-- 3 files changed, 540 insertions(+), 134 deletions(-) diff --git a/tests/test_pp_interop.py b/tests/test_pp_interop.py index 69f3308..4b00279 100644 --- a/tests/test_pp_interop.py +++ b/tests/test_pp_interop.py @@ -1,3 +1,45 @@ +"""Smoke tests for the pp_interop module. + +Three groups, each mapped to a real festival concern: + +== Conversion tests == + Does `to_pandapower` produce a structurally-correct pandapowerNet + from a `PowerGrid`? Right number of buses, lines, ext_grids; right + R per km derived from `RHO_COPPER`; right loads with P/Q from PF. + + Festival mapping: any time a planner runs the AC validator or + short-circuit calc, this is the conversion they're trusting. A + silent unit/convention bug here would miscompute everything + downstream. + +== Short-circuit tests == + Does `compute_short_circuit` give physically-sensible results that + scale correctly with cable distance and generator size? + + Festival mapping: prospective fault current at every distro is what + sizes breakers and verifies they'll trip in time on a fault. + Underestimating Isc means undersized breakers; overestimating means + paying for breakers you don't need. + +== Power-flow + comparison tests == + Does `compute_power_flow` converge and return reasonable numbers + on a trivial grid? Does `compare_with_tree_walk` line up nopywer + and pandapower views correctly? + + Festival mapping: AC validation runs once after the optimiser + produces a layout. These tests are the "did the plumbing connect" + check before the headline-finding tests in + test_pp_interop_comparison.py. + +== Defensive tests == + Bad inputs (unconnected cable, no cables, zero-length cable) + surface useful errors rather than silent garbage. + +The fixtures are deliberately tiny (1 generator + 1 load + 1 cable). +For real-world-fixture comparisons see `test_pp_interop_comparison.py`; +for the matched-assumption parity check see `test_pp_interop_parity.py`. +""" + import pytest pytest.importorskip("pandapower") @@ -13,6 +55,16 @@ def _simple_grid() -> PowerGrid: + """Trivial 1-cable grid for smoke tests. + + Generator → 50 m of 6 mm² 32 A cable → 3 kW load. Both nodes + geographically next to each other (1° of longitude apart) but + cable length is set explicitly so the geometry doesn't matter. + + Loosely models a stage being fed from a single trunk cable — + enough to exercise every code path, not enough to reveal + interesting physics. + """ gen = PowerNode(name="generator", lon=0.0, lat=0.0, is_generator=True) load = PowerNode(name="load", lon=0.001, lat=0.0, power_watts=3000.0) cable = Cable32A(id="c1", length_m=50.0, from_node="generator", to_node="load") @@ -22,7 +74,23 @@ def _simple_grid() -> PowerGrid: ) +# ============================================================ +# Conversion tests +# ============================================================ + + def test_to_pandapower_topology(): + """Pandapower net structure mirrors the PowerGrid one-to-one. + + Asserts: one bus per PowerNode, one line per Cable, exactly one + ext_grid (the slack reference for the generator), and the + line's R-per-km / max_i_ka derived correctly from + `RHO_COPPER * 1000 / area_mm2` and `plugs_and_sockets_a / 1000`. + + What this catches: silent unit errors in the conversion (mm² vs + m², kA vs A, ohm/km vs ohm/m), missing slack reference, + duplicate buses. + """ grid = _simple_grid() pp_grid = to_pandapower(grid) @@ -42,7 +110,54 @@ def test_to_pandapower_topology(): assert line["max_i_ka"] == pytest.approx(0.032) +def test_to_pandapower_creates_load_per_consumer(): + """Each non-generator node with `power_watts > 0` becomes a `pp.load`. + + Asserts: P matches `power_watts / 1e6` (MW), Q derived from + PF=0.9 via `Q = P * tan(acos(0.9)) ≈ 0.484 * P`. Generator node + is NOT a load (it's the slack, modelled by ext_grid instead). + + What this catches: P/Q sign errors, PF mis-derivation, generator + accidentally added as a load (would short the slack). + + Festival mapping: every paying customer of the festival grid is + a load — stages, kitchens, bars, distros, lights. The Q assumption + is what determines whether the AC flow reports realistic drops. + """ + grid = _simple_grid() + pp_grid = to_pandapower(grid) + assert "load" in pp_grid.load_idx + assert "generator" not in pp_grid.load_idx + load_row = pp_grid.net.load.iloc[pp_grid.load_idx["load"]] + assert load_row["p_mw"] == pytest.approx(3000 / 1e6) + # Q derived from PF=0.9 → tan(acos(0.9)) ≈ 0.4843 + assert load_row["q_mvar"] == pytest.approx(load_row["p_mw"] * 0.484, abs=0.01) + + +# ============================================================ +# Short-circuit tests +# ============================================================ + + def test_short_circuit_decreases_with_cable_distance(): + """Fault current at the load bus is smaller than at the generator. + + Physics: total impedance from the source to the fault point grows + with cable length, so `I_sc = c · Un / (√3 · Z_total)` shrinks + further from the source. Fault current at the generator bus is + bounded only by the generator's own subtransient impedance. + + What this catches: wrong impedance sign, ext_grid placed on the + wrong bus, line impedance not contributing to the fault path. + + Festival mapping: a fault at a distro at the back of the field + will draw less current than a fault at the generator. This is + why breaker selectivity matters — the breaker nearest the fault + should trip before the breaker upstream, and that's only + possible if the local fault current still exceeds the local + breaker's instantaneous trip threshold. Short-circuit calc is + the input to that selectivity analysis. + """ grid = _simple_grid() pp_grid = to_pandapower(grid) results = compute_short_circuit(pp_grid) @@ -54,6 +169,23 @@ def test_short_circuit_decreases_with_cable_distance(): def test_short_circuit_higher_for_smaller_source_impedance(): + """A bigger generator (more kVA, lower X''d) produces more fault current. + + Physics: `s_sc_max_mva = (S_n / 1000) / X''d`. Doubling S_n + halves the source impedance Z_source and roughly doubles I_sc + at the generator bus. + + What this catches: source-impedance derivation off by a factor + of √3 or 1000, or applied to the wrong element. + + Festival mapping: the choice of generator (50 kVA hire vs 500 + kVA hire) directly affects what breakers are safe to install + downstream. Breakers must cope with the WORST-case prospective + fault current — under-sizing the breaker breaking-capacity is + a fire risk. Conversely, an under-rated generator might not + deliver enough fault current to trip the breaker at all, + leaving a fault unisolated. + """ pp_small = to_pandapower(_simple_grid(), gen_sn_kva=50.0) pp_big = to_pandapower(_simple_grid(), gen_sn_kva=500.0) @@ -63,58 +195,25 @@ def test_short_circuit_higher_for_smaller_source_impedance(): assert r_big["generator"] > r_small["generator"] -def test_zero_length_cable_clamped(): - gen = PowerNode(name="generator", lon=0.0, lat=0.0, is_generator=True) - load = PowerNode(name="load", lon=0.0, lat=0.0, power_watts=1000.0) - cable = Cable16A(id="c1", length_m=0.0, from_node="generator", to_node="load") - grid = PowerGrid( - nodes={"generator": gen, "load": load}, - cables={"c1": cable}, - ) - results = compute_short_circuit(to_pandapower(grid)) - assert results["load"] > 0.0 - - -def test_unconnected_cable_raises(): - gen = PowerNode(name="generator", lon=0.0, lat=0.0, is_generator=True) - load = PowerNode(name="load", lon=0.001, lat=0.0, power_watts=1000.0) - cable = Cable16A(id="c1", length_m=50.0) - grid = PowerGrid( - nodes={"generator": gen, "load": load}, - cables={"c1": cable}, - ) - with pytest.raises(ValueError, match="not connected"): - to_pandapower(grid) - +# ============================================================ +# Power-flow + comparison tests +# ============================================================ -def test_no_cables_raises(): - gen = PowerNode(name="generator", lon=0.0, lat=0.0, is_generator=True) - grid = PowerGrid(nodes={"generator": gen}, cables={}) - with pytest.raises(ValueError, match="At least one cable"): - to_pandapower(grid) +def test_compute_power_flow_balanced_case(): + """`runpp` converges and returns volts P-N / amps in the right ranges. -def test_pandapower_grid_reusable(): - """The same PandapowerGrid can be inspected and passed to multiple calls.""" - pp_grid = to_pandapower(_simple_grid()) - r1 = compute_short_circuit(pp_grid) - # Mutating net.res_bus_sc, calling again still works and gives same answer. - r2 = compute_short_circuit(pp_grid) - assert r1 == r2 + On the trivial 1-cable 3 kW grid: generator bus near 230 V P-N, + load bus below it (positive drop), cable carries non-zero current. + What this catches: runpp not running, vm_pu → V_PN conversion + wrong (the √3 trap), result reading from the wrong dataframe. -def test_to_pandapower_creates_load_per_consumer(): - grid = _simple_grid() - pp_grid = to_pandapower(grid) - assert "load" in pp_grid.load_idx - assert "generator" not in pp_grid.load_idx - load_row = pp_grid.net.load.iloc[pp_grid.load_idx["load"]] - assert load_row["p_mw"] == pytest.approx(3000 / 1e6) - # Q derived from PF=0.9 → tan(acos(0.9)) ≈ 0.4843 - assert load_row["q_mvar"] == pytest.approx(load_row["p_mw"] * 0.484, abs=0.01) - - -def test_compute_power_flow_balanced_case(): + Festival mapping: this is the same call that the AC validator + runs on real fixtures. If this test passes and the comparison + tests still show divergence, the divergence is real physics + (not solver failure). + """ grid = _simple_grid() pp_grid = to_pandapower(grid) res = compute_power_flow(pp_grid) @@ -126,19 +225,20 @@ def test_compute_power_flow_balanced_case(): assert res.line_current_a["c1"] > 0.0 -def test_compare_with_tree_walk_after_explicit_analyze(): - """Caller may have already run analyze; we must not re-run it.""" - from nopywer.analyze import analyze +def test_compare_with_tree_walk_returns_aligned_diff(): + """`compare_with_tree_walk` returns matching keys for nopywer and AC views. - grid = _simple_grid() - analyze(grid) - # Second call would hit `_assign_children`'s cycle-detection guard if - # we re-ran analyze. Should succeed silently. - diff = compare_with_tree_walk(grid) - assert diff.converged + Asserts the diff dict has both views populated for every node and + every cable, and the `worst_*` helpers actually surface entries. + What this catches: keying mismatches between bus_idx and + PowerNode.name, or one of the views silently empty. -def test_compare_with_tree_walk_returns_aligned_diff(): + Festival mapping: this is the public diff API a planner would + call after running the optimiser. Their downstream tooling + (logging, GeoJSON enrichment, alert thresholds) depends on the + diff being structurally complete. + """ grid = _simple_grid() diff = compare_with_tree_walk(grid) assert diff.converged @@ -152,3 +252,114 @@ def test_compare_with_tree_walk_returns_aligned_diff(): worst = diff.worst_voltage_disagreement() assert worst is not None assert worst[0] in {"generator", "load"} + + +def test_compare_with_tree_walk_after_explicit_analyze(): + """Calling `compare_with_tree_walk` after the user already ran `analyze` + is a no-op, not an error. + + `analyze.py:43-49` raises if `node.children` is already populated + (cycle-detection guard). Naive double-invocation would trip this. + `compare_with_tree_walk` skips the analyze step when `grid.tree` + is non-empty. + + What this catches: regression of the idempotence guard. + + Festival mapping: the typical CLI flow already runs `analyze` + inside the optimiser, then a planner separately invokes the + validator on the same grid. Without this guard, that workflow + would crash. + """ + from nopywer.analyze import analyze + + grid = _simple_grid() + analyze(grid) + # Second call would hit `_assign_children`'s cycle-detection guard if + # we re-ran analyze. Should succeed silently. + diff = compare_with_tree_walk(grid) + assert diff.converged + + +# ============================================================ +# Reusability + defensive tests +# ============================================================ + + +def test_pandapower_grid_reusable(): + """The `PandapowerGrid` handle survives multiple calc calls. + + Calling `compute_short_circuit` twice on the same handle gives + the same answer. The first call writes into `net.res_bus_sc`; + the second call overwrites it without state leakage. + + What this catches: hidden mutation of the input net during a + calc, or one-shot guards that prevent re-running. + + Festival mapping: a planner exploring different fault types or + different generator-impedance assumptions should be able to + iterate quickly without re-converting the grid each time. + """ + pp_grid = to_pandapower(_simple_grid()) + r1 = compute_short_circuit(pp_grid) + # Mutating net.res_bus_sc, calling again still works and gives same answer. + r2 = compute_short_circuit(pp_grid) + assert r1 == r2 + + +def test_zero_length_cable_clamped(): + """A 0 m cable doesn't crash pandapower (gets clamped to MIN_LENGTH_M). + + Pandapower forbids zero-length lines (divide-by-zero in the + solver). The conversion clamps `length_m` to 1 m before + dividing by 1000. + + What this catches: regression of the clamp. + + Festival mapping: GeoJSON input where two nodes coincide + (e.g. a distro split right next to a generator) would otherwise + crash the validator. The clamp lets it run, accepting that the + 1 m floor is conservative for ultra-short connections. + """ + gen = PowerNode(name="generator", lon=0.0, lat=0.0, is_generator=True) + load = PowerNode(name="load", lon=0.0, lat=0.0, power_watts=1000.0) + cable = Cable16A(id="c1", length_m=0.0, from_node="generator", to_node="load") + grid = PowerGrid( + nodes={"generator": gen, "load": load}, + cables={"c1": cable}, + ) + results = compute_short_circuit(to_pandapower(grid)) + assert results["load"] > 0.0 + + +def test_unconnected_cable_raises(): + """A cable with empty `from_node` / `to_node` raises a clear error. + + Festival mapping: GeoJSON where a cable LineString endpoint + isn't within `CONNECTION_THRESHOLD_M` (5 m) of any node. The + user typically wants the loader (`io.load_geojson`) or + `analyze._snap_cables_to_nodes` to fix this; if the conversion + is hit with an unsnapped cable, the error message tells them. + """ + gen = PowerNode(name="generator", lon=0.0, lat=0.0, is_generator=True) + load = PowerNode(name="load", lon=0.001, lat=0.0, power_watts=1000.0) + cable = Cable16A(id="c1", length_m=50.0) + grid = PowerGrid( + nodes={"generator": gen, "load": load}, + cables={"c1": cable}, + ) + with pytest.raises(ValueError, match="not connected"): + to_pandapower(grid) + + +def test_no_cables_raises(): + """An empty cables dict raises a clear error. + + Festival mapping: a GeoJSON with only points and no line + features — caught by the conversion before pandapower + encounters an isolated bus and produces obscure singular-matrix + errors. + """ + gen = PowerNode(name="generator", lon=0.0, lat=0.0, is_generator=True) + grid = PowerGrid(nodes={"generator": gen}, cables={}) + with pytest.raises(ValueError, match="At least one cable"): + to_pandapower(grid) diff --git a/tests/test_pp_interop_comparison.py b/tests/test_pp_interop_comparison.py index 9b2439c..5bef7ed 100644 --- a/tests/test_pp_interop_comparison.py +++ b/tests/test_pp_interop_comparison.py @@ -1,28 +1,44 @@ -"""Side-by-side comparison: nopywer's tree walk vs pandapower's AC flow. +"""Side-by-side comparison: nopywer's tree walk vs pandapower's AC flow +on every existing analyze fixture. -These tests pin the divergence between the two tools on each existing -analyze fixture, so future changes to either side get caught. +== Why this file exists == -The divergence is **not one-directional** — three different shapes show -up depending on the fixture: +`test_pp_interop_parity.py` proves that under matched assumptions the +two tools agree exactly. **This file** asks the more interesting +question: when those assumptions are NOT matched (i.e. on real +fixtures with real load distributions), how much do nopywer and +pandapower disagree, and **in which direction**? -1. Low drop, balanced load - → tree walk and AC agree to within rounding. +The answer turns out to depend on the fixture. Three different +shapes show up, each mapped to a real festival situation: -2. Unbalanced single-phase loads - → nopywer's `max(current_per_phase)` rule is conservative and - **over-states** voltage drop relative to pandapower's balanced - runpp (which our converter currently uses — runpp_3ph is - opportunity §3 in 06_extended_opportunities.md). + 1. Low drop, balanced load → agreement to within rounding + 2. Unbalanced single-phase → nopywer over-states drop + 3. High drop, balanced load → nopywer under-states drop -3. High drop, balanced load - → constant-power feedback at low local voltage means more current, - more drop. The tree walk computes I at nominal V0 once and never - iterates, so it **under-states** voltage drop. +Each test below pins one of these shapes, with both direction-of- +disagreement assertions and pinned numbers (±2 pp tolerance) so +future changes to either tool are visible. -Numbers come from running compare_with_tree_walk() against each -fixture; pinned with ±0.5 V / ±0.5 % tolerance to absorb minor -numerical noise but tight enough to catch real changes. +For the underlying physics — why each direction shows up, what the +constant-current-vs-constant-power distinction means, what +max(I_per_phase) does to unbalanced loads — see +[`research/pandapower/08_why_tree_walk_and_ac_disagree.md`]( +../research/pandapower/08_why_tree_walk_and_ac_disagree.md). + +== Why both directions matter == + +A festival planner reading a single tool's output needs to know +whether it's optimistic or pessimistic in their specific scenario. +The conservative assumption ("trust the worst number") only works +if you know each tool's bias: + +- nopywer's max-phase rule is **conservative for sizing decisions** + on unbalanced single-phase loads (small distros). +- pandapower's iterative AC is **closer to physical truth on + high-stress balanced loads** (long radial trunks to heavy loads). + +These tests document those biases as code so they can't be forgotten. """ import json @@ -41,13 +57,41 @@ def test_named_generator_fixture_low_drop_parity(): - """Small balanced load — tree walk and AC agree to within rounding. - - 1 generator + 1 unphased 1 kW load through 10 m cable. Because the - load is unphased, io.load_geojson splits it evenly across phases - (333 W per phase, balanced). At ~1.6 A per phase through 20 m of - 2.5 mm² (~ 0.31 Ω), drop is well under 1 V — far too small for - constant-power feedback to matter. Both tools land within 0.1 V. + """**Mechanism 1**: small balanced load — both tools agree. + + What this is comparing + ---------------------- + Fixture: `analyze_named_generator.geojson` — 1 generator + 1 + unphased 1 kW load through 10 m of 2.5 mm² cable. + + Both views run on the same `PowerGrid`: + - nopywer: `analyze(grid)` → tree walk → drop reported on + each `PowerNode.vdrop_percent`. + - pandapower: `to_pandapower(grid)` → `runpp` → drop derived + from `res_bus.vm_pu`. + + What this is testing + -------------------- + That under the most benign conditions both simplifications are + inactive and the two tools land on essentially the same number: + + - The load is **unphased** (split evenly across L1/L2/L3) → + balanced → nopywer's `max(I_per_phase)` rule degenerates + to the right answer. + - The drop is **tiny** (~0.5 V / 0.2 %) → constant-current + (nopywer) and constant-power (pandapower) make + near-identical predictions. + + Pinned within 0.1 V / 0.05 % so a real divergence shows up but + rounding noise doesn't. + + Real-world scenario + ------------------- + A small ~1 kW distro (e.g. an info booth, a single PA stack) + plugged into a 16 A socket near the generator via a short cable. + Both tools should give the planner the same V/I numbers; if + they diverge here, something fundamental in the conversion is + broken. """ nodes, cables = load_geojson(FIXTURES / "analyze_named_generator.geojson") grid = PowerGrid(nodes=nodes, cables=cables) @@ -71,26 +115,58 @@ def test_named_generator_fixture_low_drop_parity(): def test_analyze_input_fixture_unbalanced_max_phase_overstates_drop(): - """Unbalanced single-phase loads — nopywer's max-phase rule pushes - the tree walk's reported drop **above** pandapower's balanced flow. - - Fixture: - generator → cable_0 (20 m) → load_a (3 kW on phase 1) - → cable_1 (22 m) → load_b (6 kW on phase 2) - - nopywer cumulates power per phase: cable_0 sees [3 kW, 6 kW, 0], - so current_per_phase = [14.5, 29.0, 0] A. ΔV is then computed - against `max(current_per_phase) = 29.0 A` — the worst-phase - estimate, conservative by design. - - Our `to_pandapower` collapses single-phase loads to balanced - 3-phase loads (runpp_3ph is opportunity §3). So pandapower sees - 9 kW total, balanced, with per-phase line current ~14.5 A. - Result: pandapower reports a smaller drop than the tree walk. - - This is **not a bug in either tool** — it's two different physical - models. The test pins the gap so future changes to either side - are visible. + """**Mechanism 2**: unbalanced single-phase loads — nopywer + over-states drop relative to pandapower's balanced flow. + + What this is comparing + ---------------------- + Fixture: `analyze_input.geojson` — + + generator ── cable_0 (20 m) ── load_a (3 kW on L1) + ── cable_1 (22 m) ── load_b (6 kW on L2) + + nopywer's tree walk sees per-phase power vectors: + cable_1 cum_power = [0, 6 kW, 0] → I = [0, 29 A, 0] + cable_0 cum_power = [3 kW, 6 kW, 0] → I = [14, 29, 0] A + and uses **max** of each I to compute each cable's ΔV. + + pandapower's `runpp` (after our converter collapses single-phase + loads to balanced) sees: + cable_0 carries 9 kW total → per-phase line I ≈ 14.4 A + cable_1 carries 6 kW total → per-phase line I ≈ 9.6 A + and computes balanced ΔV. + + What this is testing + -------------------- + 1. **Direction**: tree walk reports a *bigger* drop than AC at + the worst-stressed node (load_b). This is nopywer's + max-phase rule being conservative — it applies the L2 (worst) + drop to all three phases. + 2. **Magnitude**: ~5 percentage-point gap (8.13 % tree walk vs + 3.12 % AC). Documented in + `08_why_tree_walk_and_ac_disagree.md`. + 3. **Cable currents**: tree walk's reported I per cable is the + max-phase value (29 A); pandapower's is the balanced value + (10–14 A). Test pins `tw_i > ac_i` for both cables. + + Neither number is the physical truth. Truth needs `runpp_3ph` + with `asymmetric_load` (opportunity §3 in + `06_extended_opportunities.md`), which would model the actual + L1/L2/L3 distribution and the neutral conductor. + + Real-world scenario + ------------------- + A typical small-distro feed: lighting on L1, sound system on L2, + kitchen tent on L3 — single-phase loads pinned to specific + phases, deliberately balanced by the planner *across the + festival* but **unbalanced on any individual cable**. + + nopywer's number tells the planner "the WORST case at any node + is X % drop" — useful for cable-sizing decisions where you want + a safety margin. pandapower's number tells them "the AVERAGE + drop assuming the load is spread out" — useful for sanity- + checking that the typical user experience is acceptable. Neither + is wrong; they answer different questions. """ nodes, cables = load_geojson(FIXTURES / "analyze_input.geojson") grid = PowerGrid(nodes=nodes, cables=cables) @@ -114,17 +190,55 @@ def test_analyze_input_fixture_unbalanced_max_phase_overstates_drop(): def test_optimised_input_nodes_fixture_high_drop_understates_drop(): - """High-stress balanced case — constant-power feedback makes - pandapower's AC drop **larger** than the tree walk's. - - This is the headline finding from - research/pandapower/07_optimiser_validation_findings.md, captured - here as a regression check: at the worst-stressed node `glitch`, - the tree walk reports ~27 % drop and AC reports ~39 %. + """**Mechanism 3**: high-stress balanced load — nopywer + *under-states* drop because its tree walk doesn't iterate the + constant-power feedback. + + What this is comparing + ---------------------- + Fixture: `input_nodes.geojson` — the 2025 Nowhere event, 51 nodes. + Run `optimize_layout` to produce the 50-cable layout, then run + `compare_with_tree_walk` on the result. + + nopywer's tree walk computes I = P / V0 / PF **once** at nominal + voltage and never iterates. ΔV stacks linearly down the tree. + Reports `glitch` at 26.9 % drop (V = 168 V P-N). + + pandapower's `runpp` iterates: at lower local V, the + constant-power loads draw more current → more drop → even more + current → ... converges around `glitch` at 39.1 % drop (V = 140 V). + + What this is testing + -------------------- + 1. **Direction**: AC reports a *bigger* drop than tree walk at + the worst-stressed node. This is the opposite direction from + Mechanism 2 — different simplification, different bias. + 2. **Magnitude**: ~12 percentage-point gap (39 % AC vs 27 % + tree). Captured in `07_optimiser_validation_findings.md` as + the headline finding; pinned here as a regression check. + 3. **Optimisation stability**: the test uses `optimize_layout`, + which involves randomness in tie-breaking. The numbers are + pinned with ±2 pp tolerance to absorb that. If anyone "fixes" the tree walk to be more accurate (e.g. by - iterating to fixed-point), this test goes red and forces the - 07-doc finding to be updated. + iterating to a fixed point), this test will go red and force + the 07-doc finding to be updated. + + Real-world scenario + ------------------- + A long radial cable run from the generator to a remote stage + (or kitchen tent, or ice plant) — heavy balanced load drawn + through many cable hops. The kind of layout the optimiser + produces when the geographic spread of the festival forces + long trunks. + + The headline number (V at glitch = 140 V instead of 168 V) is + operationally significant: at 140 V, switch-mode PSUs trip, + motors stall, light bulbs noticeably dim. The tree walk's + 168 V is alarming but understates how bad it actually gets at + the load. **This is the case where a planner should NOT trust + nopywer alone** — running the AC validator surfaces a real + safety issue the tree walk hides. """ with open(FIXTURES / "input_nodes.geojson") as f: nodes_geojson = json.load(f) diff --git a/tests/test_pp_interop_parity.py b/tests/test_pp_interop_parity.py index 6a8fbcc..347a2fd 100644 --- a/tests/test_pp_interop_parity.py +++ b/tests/test_pp_interop_parity.py @@ -1,15 +1,61 @@ """Parity tests: nopywer's tree walk and pandapower's AC power flow agree -exactly when the modelling assumptions are matched. +exactly when their modelling assumptions are matched. -The point of these tests is to prove the **conversion is faithful** — -when both tools see the same physics, they produce the same numbers. -Any divergence on real fixtures (see tests/test_pp_interop_comparison.py) -is then known to come from physics nopywer simplifies away (load -feedback, reactive power, line reactance), not from a translation bug. +== Why this file exists == -Mirror of `test_compute_voltage_drop_uses_phase_voltage_reference` in -test_analyze.py — same hand-crafted 26 m / 1 mm² / 10 A fixture, same -expected 220 V / 4.35 % answer. +The wider comparison tests (`test_pp_interop_comparison.py`) show +nopywer and pandapower disagreeing on real fixtures by up to 12 +percentage points of voltage drop. Before reading anything into that +divergence, a reviewer needs to know it's not a trivial bug: a unit +error, a √3 mistake, a wrong R formula. This file is the proof. + +Under controlled conditions where physics guarantees agreement — +matched load model, no reactance, no constant-power feedback — the +two tools must produce **the same number to within rounding**. That's +the conversion-correctness check. If this file fails, fix the +conversion first; the comparison tests are noise until then. + +== What "matched assumptions" means == + +nopywer's tree walk computes `I = P / V0 / PF` once at nominal +voltage and uses that current everywhere. The current never updates +when the local voltage drops. That's a **constant-current load +model** in disguise. + +Pandapower's `pp.create_load` defaults to **constant-power** — the +load draws its rated P regardless of voltage, so I rises as V falls. +This is what creates the load-feedback loop and the divergence on +high-stress fixtures. + +To get parity, we ask pandapower to use the constant-current model +too: `pp.create_load(..., const_i_p_percent=100)`. Then: + +- The current is fixed at `I_rated = P_rated / V_rated`. +- No iteration on load behaviour — the answer falls out in one step. +- The only physics in play is `ΔV = R · I` and `V = V_source - ΔV`, + exactly what the tree walk computes. + +With this matched, both tools land on the same answer. + +== Festival mapping == + +This test directly mirrors +`tests/test_analyze.py::test_compute_voltage_drop_uses_phase_voltage_reference` +— Vincent's canonical unit test of the voltage-reference convention. +A festival planner reading either test should see the same fixture +(26 m of 1 mm² carrying 10 A) and the same answer (220 V at the +load, 4.35 % drop). + +The 26 / 1 / 10 numbers are chosen so the arithmetic is integer-clean: + + R = RHO_COPPER × L / A = (1/26) × 26 / 1 = 1.0 Ω exactly + ΔV = R × I = 1 × 10 = 10.0 V exactly + V_load = V0 − ΔV = 230 − 10 = 220.0 V + vdrop_% = 100 × ΔV / V0 = 100 × 10/230 = 4.347…% → 4.35 (rounded) + +Pandapower running on the same fixture in const-I mode lands on +220.016 V / 4.341 % — within 0.02 V / 0.01 % of the analytical +answer. """ import math @@ -27,21 +73,56 @@ def test_voltage_drop_matches_pandapower_with_constant_current_load(): """nopywer == pandapower when the load is modelled as constant-current. - nopywer's tree walk computes I = P / V0 / PF **once** at nominal - voltage and uses that current to derive ΔV = R·I. That's exactly the - constant-current load model in pandapower (`const_i_percent=100`): - the load draws the rated current regardless of its actual local - voltage, so there's no nonlinear feedback to iterate over. - - Same fixture as test_compute_voltage_drop_uses_phase_voltage_reference: - 26 m of 1 mm² cable, single-phase load drawing 10 A. - With RHO_COPPER = 1/26, R = (1/26) * 26 / 1 = 1.0 Ω exactly. - ΔV = R * I = 1 * 10 = 10 V exactly. - V_load = 230 - 10 = 220 V, vdrop = 100 * 10/230 = 4.35 %. + What this is comparing + ---------------------- + nopywer side: bypasses `analyze`, hand-builds the tree state, and + calls `_compute_voltage_drop` directly — exactly as the + canonical test in `tests/test_analyze.py` does. + + pandapower side: builds the same scenario from scratch (NOT via + `to_pandapower`, to keep the test transparent and + self-contained) using: + - `vn_kv` = 0.3984 kV (the L-L equivalent of 230 V P-N + that our converter uses). + - `r_ohm_per_km` = (1/26) × 1000 / 1 = 38.46 Ω/km, so + R over 26 m = 1.0 Ω. + - `x_ohm_per_km` = 1e-9 (nopywer assumes 0; pandapower's + solver init divides by x to compute susceptance and + chokes on 0, so we use a negligible non-zero value). + - A constant-current load drawing 10 A per phase line: + `p_mw = 10 × √3 × V_LL = 6.9 kW`, `const_i_p_percent=100`. + + What this is testing + -------------------- + 1. The R formula in `to_pandapower` matches `analyze.py`'s — both + give 1.0 Ω for the canonical fixture. (If R diverges the rest + can't agree.) + 2. The voltage-reference convention is right: pandapower returns + per-unit on `vn_kv` (L-L), and our `compute_power_flow` + converts that back to volts P-N via `× vn_kv × 1000 / √3`. + Both ends agree on what "230 V" means. + 3. The load model is the **only** semantic difference between + `_compute_voltage_drop` and `runpp` in this regime. Forcing + pandapower to constant-I removes that difference and proves + agreement. + + Real-world scenario + ------------------- + Imagine a single 230 V single-phase load (e.g. a small distro + feeding a couple of 16 A sockets, drawing 2300 W) at the end of + 26 m of 1 mm² cable. Both tools must report: + + V at the load = 220.0 V + voltage drop = 10 V (4.35 %) + cable current per phase = 10 A + + A festival electrician working from either nopywer's report or + pandapower's runpp output should see the same number — and that + number should match what they'd compute by hand. """ # --- nopywer side: bypass `analyze`, hand-build the tree state # and call `_compute_voltage_drop` directly, exactly as the - # original test does. --- + # canonical test does. --- generator = PowerNode( name="generator", lon=0.0, @@ -71,7 +152,7 @@ def test_voltage_drop_matches_pandapower_with_constant_current_load(): ) _compute_voltage_drop(grid) - # Sanity: the nopywer numbers are the ones the original test pins. + # Sanity: the nopywer numbers are the ones the canonical test pins. assert cable.vdrop_volts == 10.0 assert load.voltage == 220.0 assert load.vdrop_percent == 4.35 From 347172d05bedb4f2f0ed5a1ec671927fe455f0b1 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 22:04:46 +0200 Subject: [PATCH 05/14] test(pp_interop): topology comparison tests for three festival shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Group A from the coverage gap analysis: synthetic fixtures that isolate the effect of grid SHAPE on the tree-walk-vs-AC gap, with load type and config defaults held constant. Three tests, three layouts a planner regularly sketches: - Deep linear chain (gen→A→B→C→D, each tapping 5 kW through a 32 A cable). Tests constant-power feedback compounding with depth: the gap grows monotonically (A=+0.02, B=+0.07, C=+0.10, D=+0.12 pp). Models a back-of-house corridor: kitchen → dressing → green room → workshop, each distro tapping its load. - Wide star (gen → 5 parallel branches × 5 kW each). Tests branch independence: with no chaining there's no feedback compounding. All branches identical (tw 1.78 % / ac 1.65 % / gap -0.13 pp at every leaf). Models a central distro hub feeding stages radially — the case where the planner can trust the tree walk. - Bottleneck (thin 16 A trunk → distro → three 32 A spurs). Tests drop attribution: even when magnitudes diverge, both tools should agree the trunk dominates. They do — trunk carries 96 % of the total drop to any leaf, in both views. Models the case where a planner reuses an existing thin cable run and fans out from there. Each test pins direction-of-disagreement plus magnitude bounds (±0.5 pp on the gap, >75 % attribution share). Synthetic fixtures are built in code via two small helpers (_node, _cable) that populate `power_per_phase` the way io.load_geojson would. Module docstring frames all three as "sensible planner shapes" and links to research/pandapower/08 for the underlying physics. --- tests/test_pp_interop_topology.py | 353 ++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 tests/test_pp_interop_topology.py diff --git a/tests/test_pp_interop_topology.py b/tests/test_pp_interop_topology.py new file mode 100644 index 0000000..269fee8 --- /dev/null +++ b/tests/test_pp_interop_topology.py @@ -0,0 +1,353 @@ +"""Topology comparison tests: how the tree-walk-vs-AC gap changes with +grid SHAPE. + +The fixtures in `test_pp_interop_comparison.py` cover three real +festival layouts but vary on multiple axes at once (size, phase +distribution, load magnitude). This file holds load type and config +defaults constant and varies only the topology, so each test isolates +**one geometric effect**. + +== The three shapes == + +A festival planner regularly sketches one of three patterns: + + 1. Deep linear chain gen ─→ A ─→ B ─→ C ─→ D + Common for back-of-house corridors: generator at the gate, + then kitchen → dressing room → green room → workshop, each + distro tapping its load and passing the rest through. + + 2. Wide star gen ─┬─→ stage 1 + ├─→ stage 2 + ├─→ stage 3 + ├─→ stage 4 + └─→ stage 5 + Common when a central distro hub feeds independent areas in + different directions. Each branch carries its own load with + no compounding from siblings. + + 3. Bottleneck gen ─→ [thin cable] ─→ distro ─┬─→ load + ├─→ load + └─→ load + Common when a planner has the wrong-tier cable on hand or + wants to reuse an existing run. The thin section limits every + downstream load even if their own cables are ample. + +== What each test isolates == + +Deep chain → tests **constant-power feedback compounding** with + depth. Each cable's drop nudges the next iteration's + current. AC-vs-tree-walk gap should grow with depth. + +Wide star → tests **branch independence**. No chaining = no + compounding feedback. AC and tree walk should agree + closely on every leaf. + +Bottleneck → tests **drop attribution**. Even when the magnitudes + diverge, both tools should agree that the thin cable + dominates the total drop. + +For the underlying physics, see +[`research/pandapower/08_why_tree_walk_and_ac_disagree.md`]( +../research/pandapower/08_why_tree_walk_and_ac_disagree.md). +""" + +import pytest + +pytest.importorskip("pandapower") + +from nopywer.models import Cable, PowerGrid, PowerNode +from nopywer.pp_interop import compare_with_tree_walk + + +def _node(name: str, lon: float, power_kw: float = 0.0, *, generator: bool = False) -> PowerNode: + """Helper: build a PowerNode at given longitude (lat=0). + + Power is in kW for readability — converted to W internally. + Loads are unphased (split balanced across L1/L2/L3) so we isolate + topology effects from phase imbalance. We populate `power_per_phase` + here because `io.load_geojson` does that step (the dataclass + default leaves it as zeros), and the tree walk reads it directly. + """ + node = PowerNode( + name=name, + lon=lon, + lat=0.0, + power_watts=power_kw * 1000.0, + is_generator=generator, + ) + if not generator and power_kw > 0: + node.power_per_phase += (power_kw * 1000.0) / 3 + return node + + +def _cable(cid: str, frm: str, to: str, length_m: float, area_mm2: float, ps_a: float) -> Cable: + """Helper: build a snapped Cable between two named nodes.""" + return Cable( + id=cid, + length_m=length_m, + area_mm2=area_mm2, + plugs_and_sockets_a=ps_a, + from_node=frm, + to_node=to, + ) + + +def test_deep_linear_chain_compounds_drop_with_depth(): + """**Deep chain**: gen → A → B → C → D, each distro tapping 5 kW. + + Topology + -------- + Five nodes in a line, four cables. 50 m between each, 32 A + cables (6 mm²) — within rating throughout but stressed enough + to make the constant-power feedback bite. + + gen ─[50 m]→ A(5kW) ─[50 m]→ B(5kW) ─[50 m]→ C(5kW) ─[50 m]→ D(5kW) + + Cumulative power on each cable, leaves → root: + cable_d: 5 kW + cable_c: 10 kW + cable_b: 15 kW + cable_a: 20 kW + + What this is testing + -------------------- + 1. **Direction**: AC drop at every depth ≥ tree-walk drop. The + constant-current shortcut in nopywer always under-states in + this regime. + 2. **Compounding**: the AC-vs-tree gap **grows monotonically + with depth**. A is barely off; D shows the biggest gap. + This is the signature of feedback compounding — each level + deeper, the iteration has done one more round of "lower V → + more I → more drop". + + Real-world scenario + ------------------- + A row of distros down a back-of-house corridor: kitchen (A) → + dressing room (B) → green room (C) → tech workshop (D). The + planner's tree walk says D will be at, say, V_tw V. The AC + truth is **lower** — and the gap matters more the deeper you + go. This is the case where a planner reading nopywer's output + might think "5% drop at D, fine" when AC would say "8% drop, + light bulbs visibly dim". + """ + nodes = { + "gen": _node("gen", 0.0, generator=True), + "a": _node("a", 0.001, 5.0), + "b": _node("b", 0.002, 5.0), + "c": _node("c", 0.003, 5.0), + "d": _node("d", 0.004, 5.0), + } + cables = { + "cable_a": _cable("cable_a", "gen", "a", 50.0, area_mm2=6.0, ps_a=32.0), + "cable_b": _cable("cable_b", "a", "b", 50.0, area_mm2=6.0, ps_a=32.0), + "cable_c": _cable("cable_c", "b", "c", 50.0, area_mm2=6.0, ps_a=32.0), + "cable_d": _cable("cable_d", "c", "d", 50.0, area_mm2=6.0, ps_a=32.0), + } + grid = PowerGrid(nodes=nodes, cables=cables) + + diff = compare_with_tree_walk(grid) + assert diff.converged + + # Direction: AC drop ≥ tree walk drop at every node downstream + # of the generator. + for name in ("a", "b", "c", "d"): + tw_pct, ac_pct, _ = diff.bus_vdrop_percent[name] + assert ac_pct >= tw_pct - 0.05, ( + f"{name}: expected AC drop ≥ tree-walk drop, got " + f"AC={ac_pct:.2f} % vs tree={tw_pct:.2f} %" + ) + + # Compounding: the gap at depth 4 (d) is strictly bigger than + # the gap at depth 1 (a). This is the signature of constant-P + # feedback compounding. + _, _, gap_a = diff.bus_vdrop_percent["a"] + _, _, gap_d = diff.bus_vdrop_percent["d"] + assert gap_d > gap_a, ( + f"expected gap to grow with depth, got " + f"gap@a={gap_a:.3f} %, gap@d={gap_d:.3f} %" + ) + + +def test_wide_star_branches_are_independent(): + """**Wide star**: gen → 5 parallel 5 kW loads, no chaining. + + Topology + -------- + Five branches off the generator, each a single 80 m / 32 A + cable to one 5 kW load. No depth, no chaining, no compounding + feedback between branches. + + gen ─┬─[80m]→ stage_1 (5 kW) + ├─[80m]→ stage_2 (5 kW) + ├─[80m]→ stage_3 (5 kW) + ├─[80m]→ stage_4 (5 kW) + └─[80m]→ stage_5 (5 kW) + + Total cumulative power at the generator: 25 kW. Each cable + independently carries 5 kW. + + What this is testing + -------------------- + 1. **Symmetry**: every branch should report the same drop — + no branch sees the others' currents. + 2. **Branch independence**: AC and tree walk agree closely on + every leaf. Without depth, the constant-P feedback only + runs one iteration deep, so the gap stays small (<0.5 pp). + 3. **Sanity**: this should look like 5 copies of a single + 1-cable case, not like a deep tree. + + Real-world scenario + ------------------- + A central distribution hub feeding multiple stages or zones + radially. The planner uses nopywer to design the layout; for + this shape, **the tree walk and AC truth are nearly the same** + and the planner can trust nopywer's numbers without worrying + about the iterative AC validation. + + Contrast with `test_deep_linear_chain_compounds_drop_with_depth` + — same total power (20–25 kW), same cable tier — but a wide + layout instead of deep, and the gap collapses. + """ + nodes = {"gen": _node("gen", 0.0, generator=True)} + cables: dict[str, Cable] = {} + for i in range(5): + name = f"stage_{i + 1}" + # Lay branches at slightly different lon to keep nodes distinct. + nodes[name] = _node(name, 0.001 * (i + 1), 5.0) + cables[f"cable_{i + 1}"] = _cable( + f"cable_{i + 1}", "gen", name, 80.0, area_mm2=6.0, ps_a=32.0 + ) + grid = PowerGrid(nodes=nodes, cables=cables) + + diff = compare_with_tree_walk(grid) + assert diff.converged + + # All branches symmetric: every leaf reports the same drop. + leaf_drops_tw = [diff.bus_vdrop_percent[f"stage_{i + 1}"][0] for i in range(5)] + leaf_drops_ac = [diff.bus_vdrop_percent[f"stage_{i + 1}"][1] for i in range(5)] + assert max(leaf_drops_tw) - min(leaf_drops_tw) < 0.05, ( + f"tree walk should report identical drops on symmetric branches; " + f"got {leaf_drops_tw}" + ) + assert max(leaf_drops_ac) - min(leaf_drops_ac) < 0.05, ( + f"AC should report identical drops on symmetric branches; " + f"got {leaf_drops_ac}" + ) + + # Branch independence: AC and tree walk agree to within 0.5 pp on + # every leaf. Without depth there's no feedback compounding. + for i in range(5): + name = f"stage_{i + 1}" + tw_pct, ac_pct, gap = diff.bus_vdrop_percent[name] + assert abs(gap) < 0.5, ( + f"{name}: expected near-parity (|gap| < 0.5 pp) on a wide " + f"star branch, got tree={tw_pct:.2f} %, AC={ac_pct:.2f} %, " + f"gap={gap:+.2f} pp" + ) + + +def test_bottleneck_thin_cable_dominates_drop_attribution(): + """**Bottleneck**: a thin 16 A trunk feeding three 32 A spurs. + + Topology + -------- + A thin (2.5 mm² / 16 A) trunk runs 100 m from the generator to + a central distro. From there, three 30 m / 6 mm² / 32 A spurs + fan out to three 3 kW loads. + + gen ─[100 m, 2.5 mm²]→ distro ─┬─[30 m, 6 mm²]→ load_1 (3 kW) + ├─[30 m, 6 mm²]→ load_2 (3 kW) + └─[30 m, 6 mm²]→ load_3 (3 kW) + + Total downstream power: 9 kW, balanced across phases. + Per-phase line current on the trunk: ~14.5 A (under 16 A). + Per-phase line current on each spur: ~4.8 A (well under 32 A). + + The trunk is the bottleneck: + R_trunk = (1/26) × 100 / 2.5 ≈ 1.54 Ω + R_spur = (1/26) × 30 / 6.0 ≈ 0.19 Ω + + Trunk R is ~8× spur R. Even at 1/3 the current, the trunk + dominates total drop. + + What this is testing + -------------------- + 1. **Attribution**: both tools agree that the trunk carries + the bulk of the drop (>75 % of the total path loss to any + leaf). Even if magnitudes diverge, the **shape** of the + drop along the path should match. + 2. **Symmetry**: the three downstream loads see identical + drops (their spurs are symmetric). + 3. **Both tools surface the bottleneck**: tree walk and AC + both report the largest cable current on the trunk, not + on a spur. + + Real-world scenario + ------------------- + A planner reuses an existing 100 m run of 2.5 mm² flex from + the gen to a central area, then fans out with proper 32 A + cables. The thin trunk **looks fine on paper** (14.5 A < 16 A + rating) but its high R per metre dominates voltage drop. Both + nopywer and pandapower should make this obvious in their + output — the planner who reads either report should see "the + drop is in the trunk, the spurs are negligible" and consider + upgrading the trunk to a 32 A or 63 A cable. + """ + nodes = { + "gen": _node("gen", 0.0, generator=True), + "distro": _node("distro", 0.001), + "load_1": _node("load_1", 0.002, 3.0), + "load_2": _node("load_2", 0.0021, 3.0), + "load_3": _node("load_3", 0.0022, 3.0), + } + # Ensure distinct lat so nodes don't coincide with the trunk endpoint. + nodes["load_1"].lat = 0.0001 + nodes["load_2"].lat = 0.0002 + nodes["load_3"].lat = 0.0003 + cables = { + "trunk": _cable("trunk", "gen", "distro", 100.0, area_mm2=2.5, ps_a=16.0), + "spur_1": _cable("spur_1", "distro", "load_1", 30.0, area_mm2=6.0, ps_a=32.0), + "spur_2": _cable("spur_2", "distro", "load_2", 30.0, area_mm2=6.0, ps_a=32.0), + "spur_3": _cable("spur_3", "distro", "load_3", 30.0, area_mm2=6.0, ps_a=32.0), + } + grid = PowerGrid(nodes=nodes, cables=cables) + + diff = compare_with_tree_walk(grid) + assert diff.converged + + # Both tools agree the trunk carries the highest current. + tw_currents = {cid: diff.line_current_a[cid][0] for cid in diff.line_current_a} + ac_currents = {cid: diff.line_current_a[cid][1] for cid in diff.line_current_a} + assert max(tw_currents, key=tw_currents.get) == "trunk", ( + f"tree walk should surface the trunk as highest-current; got {tw_currents}" + ) + assert max(ac_currents, key=ac_currents.get) == "trunk", ( + f"AC should surface the trunk as highest-current; got {ac_currents}" + ) + + # Attribution: most of the drop to any load is in the trunk. + # Compute per-cable ΔV from tree-walk side. + distro_drop_tw = diff.bus_vdrop_percent["distro"][0] + leaf_drop_tw = diff.bus_vdrop_percent["load_1"][0] + trunk_share_tw = distro_drop_tw / leaf_drop_tw if leaf_drop_tw > 0 else 0 + assert trunk_share_tw > 0.75, ( + f"tree walk: trunk should carry >75 % of drop to leaf; got " + f"trunk drop={distro_drop_tw:.2f} % vs leaf drop={leaf_drop_tw:.2f} % " + f"(share={trunk_share_tw:.0%})" + ) + + # Same attribution check on the AC side. + distro_drop_ac = diff.bus_vdrop_percent["distro"][1] + leaf_drop_ac = diff.bus_vdrop_percent["load_1"][1] + trunk_share_ac = distro_drop_ac / leaf_drop_ac if leaf_drop_ac > 0 else 0 + assert trunk_share_ac > 0.75, ( + f"AC: trunk should carry >75 % of drop to leaf; got " + f"trunk drop={distro_drop_ac:.2f} % vs leaf drop={leaf_drop_ac:.2f} % " + f"(share={trunk_share_ac:.0%})" + ) + + # Symmetry: the three spurs see identical drops in each tool. + spur_drops_tw = [diff.bus_vdrop_percent[n][0] for n in ("load_1", "load_2", "load_3")] + spur_drops_ac = [diff.bus_vdrop_percent[n][1] for n in ("load_1", "load_2", "load_3")] + assert max(spur_drops_tw) - min(spur_drops_tw) < 0.05 + assert max(spur_drops_ac) - min(spur_drops_ac) < 0.05 From b6b9d39b785ec6be2acd972d342657f02d7a37dd Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 22:12:35 +0200 Subject: [PATCH 06/14] chore(scripts): sensitivity sweep over high-impact pp_interop config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/sensitivity_sweep.py — three tight sweeps around the defaults in pp_interop/config.py, each printing a table showing how the AC vs tree-walk gap responds. Findings on the deep-chain fixture (4 cables × 50 m, 5 kW × 4 loads): X_OHM_PER_KM 0.05 → 0.12 (default 0.08) AC drop at d: 11.312 % → 11.373 % (span 0.06 pp) essentially negligible at festival scales — the 0.08 default is fine even if individual cables differ ±50 %. PF 0.80 → 1.00 (default 0.9) AC drop at d: 11.543 % → 11.151 % (span 0.39 pp) gap (AC − tree): +0.323 → −0.069 pp crosses zero around PF ≈ 0.97. Real festival kitchens at PF ≈ 0.7 would push the gap further open; this is the knob worth varying per-load if/when load_type lands. GEN_XDSS_PU 0.08 → 0.18 (default 0.12) Isc at gen: 1.811 kA → 0.805 kA (2.2× range) biggest lever overall. Choice of generator impedance materially affects breaker breaking-capacity sizing. Run with: uv run python scripts/sensitivity_sweep.py Output is reproducible (no randomness). Not a test because the useful artifact is the printed table, not a pass/fail; pinning specific numbers as test assertions would obscure the gradient we actually care about. --- scripts/sensitivity_sweep.py | 184 +++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 scripts/sensitivity_sweep.py diff --git a/scripts/sensitivity_sweep.py b/scripts/sensitivity_sweep.py new file mode 100644 index 0000000..7ce44ee --- /dev/null +++ b/scripts/sensitivity_sweep.py @@ -0,0 +1,184 @@ +"""Sensitivity sweep over the high-impact `pp_interop/config.py` defaults. + +Probes how the AC vs tree-walk gap responds to small changes around +each default. Three sweeps: + + 1. Cable reactance `X_OHM_PER_KM` default 0.08 Ω/km + → range 0.05 .. 0.12 in 0.01 steps + → reads: AC drop at the deepest node of a 4-deep chain + + 2. Load power factor `PF` default 0.9 + → range 0.80 .. 1.00 in 0.025 steps + → reads: AC drop at the deepest node of a 4-deep chain + + 3. Generator subtransient reactance `GEN_XDSS_PU` default 0.12 pu + → range 0.08 .. 0.18 in 0.01 steps + → reads: short-circuit current at the generator bus + +Each sweep prints a table: + + parameter value | tree-walk metric | AC metric | gap (AC − tree) + pp / kA / etc. + +Run with: + uv run python scripts/sensitivity_sweep.py +""" + +from nopywer.models import Cable, PowerGrid, PowerNode +from nopywer.pp_interop import compare_with_tree_walk, compute_short_circuit, to_pandapower + + +def _node(name: str, lon: float, power_kw: float = 0.0, *, generator: bool = False) -> PowerNode: + node = PowerNode( + name=name, + lon=lon, + lat=0.0, + power_watts=power_kw * 1000.0, + is_generator=generator, + ) + if not generator and power_kw > 0: + node.power_per_phase += (power_kw * 1000.0) / 3 + return node + + +def _cable(cid: str, frm: str, to: str, length_m: float, area_mm2: float, ps_a: float) -> Cable: + return Cable( + id=cid, + length_m=length_m, + area_mm2=area_mm2, + plugs_and_sockets_a=ps_a, + from_node=frm, + to_node=to, + ) + + +def deep_chain_grid() -> PowerGrid: + """5 nodes in a chain, 5 kW each, 50 m / 32 A cables. + + Same fixture as `tests/test_pp_interop_topology.py` deep-chain + test. Stressed enough that the AC vs tree-walk gap is non-zero + but not extreme. Rebuild for each sweep value because `analyze` + mutates state. + """ + return PowerGrid( + nodes={ + "gen": _node("gen", 0.0, generator=True), + "a": _node("a", 0.001, 5.0), + "b": _node("b", 0.002, 5.0), + "c": _node("c", 0.003, 5.0), + "d": _node("d", 0.004, 5.0), + }, + cables={ + "cable_a": _cable("cable_a", "gen", "a", 50.0, area_mm2=6.0, ps_a=32.0), + "cable_b": _cable("cable_b", "a", "b", 50.0, area_mm2=6.0, ps_a=32.0), + "cable_c": _cable("cable_c", "b", "c", 50.0, area_mm2=6.0, ps_a=32.0), + "cable_d": _cable("cable_d", "c", "d", 50.0, area_mm2=6.0, ps_a=32.0), + }, + ) + + +def one_cable_grid() -> PowerGrid: + """Trivial 1-cable grid for the short-circuit sweep. + + A single 3 kW load through 50 m of 32 A cable. For Isc the + fixture topology barely matters; we want the simplest possible + grid so the source-impedance term dominates the result. + """ + return PowerGrid( + nodes={ + "gen": _node("gen", 0.0, generator=True), + "load": _node("load", 0.001, 3.0), + }, + cables={ + "c1": _cable("c1", "gen", "load", 50.0, area_mm2=6.0, ps_a=32.0), + }, + ) + + +def _frange(start: float, stop: float, step: float) -> list[float]: + """Inclusive float range that doesn't drift due to repeated addition.""" + out = [] + n = 0 + while True: + v = start + n * step + if v > stop + 1e-9: + break + out.append(round(v, 6)) + n += 1 + return out + + +def sweep_x_ohm_per_km() -> None: + """X reactance sweep, 0.05–0.12 Ω/km in 0.01 steps. Default 0.08. + + Tree walk ignores X entirely. Pandapower includes the X·sin φ term + in voltage drop. Larger X → larger AC drop. Tree-walk drop is + constant across the sweep. + """ + print("== X_OHM_PER_KM sweep (default 0.08, ±range 0.05–0.12) ==") + print("fixture: deep chain, 4 cables × 50 m, 5 kW load at each leaf") + print(f"{'X Ω/km':>10} {'tree d %':>10} {'AC d %':>10} {'gap pp':>10}") + print("-" * 46) + base_x = 0.08 + for x in _frange(0.05, 0.12, 0.01): + diff = compare_with_tree_walk(deep_chain_grid(), x_ohm_per_km=x) + tw, ac, gap = diff.bus_vdrop_percent["d"] + marker = " ← default" if abs(x - base_x) < 1e-6 else "" + print(f"{x:>10.3f} {tw:>10.3f} {ac:>10.3f} {gap:>+10.3f}{marker}") + print() + + +def sweep_pf() -> None: + """Load PF sweep, 0.80–1.00 in 0.025 steps. Default 0.9. + + nopywer's PF is hardcoded in `nopywer.constants`, so the tree-walk + drop is constant across the sweep — only pandapower's Q-mvar moves. + Lower PF → more reactive current → bigger AC drop. + + Reading the gap: at PF = 0.9 the two tools share an assumption, + so the gap is the "topology + load-feedback" gap. At PF ≠ 0.9 the + gap also includes "the load is more/less reactive than nopywer + assumed". + """ + print("== PF sweep (default 0.9, ±range 0.80–1.00) ==") + print("fixture: deep chain (same as X sweep)") + print("note: nopywer's PF is fixed at 0.9; only AC side moves") + print(f"{'PF':>8} {'tree d %':>10} {'AC d %':>10} {'gap pp':>10}") + print("-" * 44) + base_pf = 0.9 + for pf in _frange(0.80, 1.00, 0.025): + diff = compare_with_tree_walk(deep_chain_grid(), load_pf=pf) + tw, ac, gap = diff.bus_vdrop_percent["d"] + marker = " ← default" if abs(pf - base_pf) < 1e-6 else "" + print(f"{pf:>8.3f} {tw:>10.3f} {ac:>10.3f} {gap:>+10.3f}{marker}") + print() + + +def sweep_gen_xdss_pu() -> None: + """Generator X''d sweep, 0.08–0.18 pu in 0.01 steps. Default 0.12. + + Affects short-circuit only. `s_sc_max_mva = (S_n / 1000) / X''d`, + so smaller X''d → bigger source MVA → bigger Isc. + """ + print("== GEN_XDSS_PU sweep (default 0.12, ±range 0.08–0.18) ==") + print("fixture: 1 cable, 50 m / 32 A, 3 kW load") + print("metric: Isc at the generator bus, IEC 60909 3-phase max-case") + print(f"{'X''d pu':>10} {'Isc gen kA':>12} {'Isc load kA':>13}") + print("-" * 39) + base_xdss = 0.12 + for xdss in _frange(0.08, 0.18, 0.01): + pp_grid = to_pandapower(one_cable_grid(), gen_xdss_pu=xdss) + results = compute_short_circuit(pp_grid) + marker = " ← default" if abs(xdss - base_xdss) < 1e-6 else "" + print(f"{xdss:>10.3f} {results['gen']:>12.4f} {results['load']:>13.4f}{marker}") + print() + + +def main() -> None: + sweep_x_ohm_per_km() + sweep_pf() + sweep_gen_xdss_pu() + + +if __name__ == "__main__": + main() From 5bc2729a61befb79cff91cfd2f96c1a1fbddb0de Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 22:15:36 +0200 Subject: [PATCH 07/14] docs(research): sensitivity to pp_interop config assumptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the findings from running scripts/sensitivity_sweep.py: X_OHM_PER_KM 0.05 → 0.12 → AC drop moves 0.06 pp (negligible) PF 0.80 → 1.00 → AC drop moves 0.39 pp (gap flips at PF≈0.97) GEN_XDSS_PU 0.08 → 0.18 → Isc moves 2.2× (1.81 → 0.81 kA) Verdict per knob: X is fine to leave at 0.08 (festival cables are short and resistive enough that the X·sin φ term barely contributes). PF is a modest lever and the most useful per-load knob if/when load_type lands. GEN_XDSS_PU is the one to override per-event — ignoring the actual hired generator's X''d can mis-size breakers by a factor of 2. Cross-cutting: at festival scales the AC vs tree-walk gap is dominated by cable R and topology, not by these per-cable / per-load constants. RHO_COPPER deliberately not swept because both tools share it (gap invariant; absolute numbers would scale). Reproducible: re-run scripts/sensitivity_sweep.py after any change to pp_interop/config.py defaults to refresh the numbers in this doc. --- .../09_sensitivity_to_config_assumptions.md | 187 ++++++++++++++++++ research/pandapower/README.md | 6 + 2 files changed, 193 insertions(+) create mode 100644 research/pandapower/09_sensitivity_to_config_assumptions.md diff --git a/research/pandapower/09_sensitivity_to_config_assumptions.md b/research/pandapower/09_sensitivity_to_config_assumptions.md new file mode 100644 index 0000000..7fd7ba2 --- /dev/null +++ b/research/pandapower/09_sensitivity_to_config_assumptions.md @@ -0,0 +1,187 @@ +# Sensitivity to pp_interop config assumptions + +The `pp_interop/config.py` defaults are documented as "industry +typical" but until now we had no measurement of how much each +assumption actually moves the planner-facing numbers. +`scripts/sensitivity_sweep.py` runs three tight sweeps around each +default on a deep-chain fixture (4 cables × 50 m, 5 kW × 4 loads — +representative of a back-of-house corridor). This doc captures what +the sweeps revealed and what each finding means for a planner. + +## TL;DR + +| Knob | Default | Tested range | Impact at festival scales | Verdict | +|---|---:|---|---|---| +| `X_OHM_PER_KM` | 0.08 | 0.05 – 0.12 | AC drop moves **0.06 pp** across the full range | Negligible — leave as-is | +| `PF` (load) | 0.9 | 0.80 – 1.00 | AC drop moves **0.39 pp**; sign of gap flips at PF ≈ 0.97 | Modest, but the most useful per-load knob if `load_type` ever lands | +| `GEN_XDSS_PU` | 0.12 | 0.08 – 0.18 | Isc moves **2.2×** (1.81 kA → 0.81 kA at the gen bus) | **Biggest lever**. Worth overriding per-event for breaker sizing | + +## Sweep 1 — Cable reactance `X_OHM_PER_KM` + +Default: 0.08 Ω/km. Tested: 0.05 → 0.12 in 0.01 steps. + +``` + X Ω/km tree d % AC d % gap pp +---------------------------------------------- + 0.050 11.220 11.312 +0.092 + 0.060 11.220 11.321 +0.101 + 0.070 11.220 11.330 +0.110 + 0.080 11.220 11.339 +0.119 ← default + 0.090 11.220 11.347 +0.127 + 0.100 11.220 11.356 +0.136 + 0.110 11.220 11.365 +0.145 + 0.120 11.220 11.373 +0.153 +``` + +**What it shows.** Tree walk is invariant (it ignores X). AC drop +grows linearly with X, but the slope is ~0.7 pp per Ω/km. Across +the full ±50 % swing around the default, the AC answer moves only +0.06 pp. + +**Why.** The reactive component of voltage drop is `I · X · sin φ`. +At PF = 0.9, sin φ = 0.435. With per-cable I ≈ 32 A and total +length ≈ 200 m, the X contribution to each cable's drop is small +relative to the resistive `I · R · cos φ` term. Festival cables +are short and resistive — exactly the regime where X is a +second-order effect. + +**Implication for the planner.** The 0.08 default is fine. Even if +a particular cable spec sheet quotes 0.07 or 0.10, the impact on +the AC validator's output is below the rounding the tree walk +already does. **No need to expose this as a per-cable parameter +unless someone runs validators on > 1 km of cable.** + +## Sweep 2 — Load power factor `PF` + +Default: 0.9. Tested: 0.80 → 1.00 in 0.025 steps. + +``` + PF tree d % AC d % gap pp +-------------------------------------------- + 0.800 11.220 11.543 +0.323 + 0.825 11.220 11.486 +0.266 + 0.850 11.220 11.433 +0.213 + 0.875 11.220 11.384 +0.164 + 0.900 11.220 11.339 +0.119 ← default + 0.925 11.220 11.295 +0.075 + 0.950 11.220 11.253 +0.033 + 0.975 11.220 11.210 -0.010 + 1.000 11.220 11.151 -0.069 +``` + +**What it shows.** Tree walk is invariant — `PF` is hardcoded in +`nopywer.constants.PF`. Only the AC side moves. Lower PF → +bigger AC drop. The gap **changes sign around PF ≈ 0.97**: above +that, AC reports *less* drop than tree walk; below, more. + +**Why.** Lower PF = more reactive current to deliver the same P = +more total current = more drop. At PF = 1.0 there's no Q at all +and the constant-power feedback loop barely runs (no voltage- +sensitive Q to amplify), so AC is actually slightly *under* the +tree walk's conservative single-pass answer. + +**Implication for the planner.** The default 0.9 is a reasonable +festival average. But: + +- A real **kitchen tent** at PF ≈ 0.7 (motors, fridges) would push + the AC gap further open: extrapolating linearly, AC drop would + be roughly +0.5 pp above tree walk on this fixture. Not + catastrophic, but visible. +- A real **LED-only stage** at PF ≈ 1.0 would have AC reporting + *less* drop than tree walk. Tree walk over-states by ~0.07 pp. + +The interesting consequence: **nopywer's tree walk is roughly +right when the load mix really does average 0.9 PF**, but it +silently over-estimates drop for resistive loads and under- +estimates for inductive ones. A future per-load `load_type` +field (opportunity §2 in +[`06_extended_opportunities.md`](./06_extended_opportunities.md)) +would let `to_pandapower` pass per-load Q values and capture this +properly. + +## Sweep 3 — Generator subtransient reactance `GEN_XDSS_PU` + +Default: 0.12 pu (typical diesel). Tested: 0.08 → 0.18 in 0.01 steps. + +``` + X''d pu Isc gen kA Isc load kA +----------------------------------------- + 0.080 1.8115 0.6957 + 0.090 1.6102 0.6794 + 0.100 1.4492 0.6627 + 0.110 1.3174 0.6460 + 0.120 1.2076 0.6293 ← default + 0.130 1.1147 0.6127 + 0.140 1.0351 0.5963 + 0.150 0.9661 0.5803 + 0.160 0.9057 0.5646 + 0.170 0.8525 0.5493 + 0.180 0.8051 0.5345 +``` + +**What it shows.** Isc at the generator bus moves from 1.81 kA at +X″d = 0.08 down to 0.81 kA at 0.18 — a **2.2× range** across the +realistic 0.08 – 0.18 sweep. At the load bus (50 m of 32 A cable +downstream), the cable impedance dominates and the response is +much weaker (0.70 → 0.53 kA, 1.3× range). + +**Why.** Source short-circuit power is `S_sc = S_n / X''d`. Halve +X″d and you double the available fault current at the gen bus. +Cable impedance attenuates this further downstream, so the lever +is biggest where there's no cable in the way. + +**Implication for the planner.** This is the single most +important assumption to **override per-event**. Real festivals +hire one of: + +- A large **salient-pole synchronous machine** (X″d ≈ 0.08) → + high Isc, breakers must have ≥ 2 kA breaking capacity. +- A typical **diesel gen-set** (X″d ≈ 0.12, the default) → + ~1.2 kA Isc. Most 6 kA breakers are over-rated for this. +- A **gas turbine** or smaller-frame machine (X″d ≈ 0.18) → + ~0.8 kA Isc. Standard breakers fine, but worth checking the + *minimum* fault current for RCD trip. + +A planner running `compute_short_circuit` without overriding +`gen_xdss_pu` for the actual hired machine could get the breaker +selection wrong by a factor of 2. **Worth flagging in the CLI +output** — "computed assuming X″d = 0.12 pu (typical diesel); +override via gen_xdss_pu= for the actual machine". + +## Cross-cutting observations + +**The two power-flow knobs (X, PF) are second-order at festival +scales.** Even varying them simultaneously to their pessimistic +ends (X = 0.12, PF = 0.80) only moves AC drop by ~0.4 pp from the +default. The tree walk and AC flow are **dominated by the cable +R and the topology**, not by these per-cable / per-load constants. + +**The fault-calc knob (X″d) is first-order.** Source impedance is +the only meaningful contributor to fault current at the gen bus, +and changing it by a factor of 2.25 changes Isc by exactly that. +A planner who ignores this is making a real safety error. + +**What we are NOT measuring.** This sweep holds the fixture +constant and varies one parameter at a time. We don't measure: + +- **Compounding effects** between knobs (e.g. low PF + high X + combined). The expectation is that they superpose linearly at + these scales, but it's untested. +- **Sensitivity to `RHO_COPPER`**. Both tools share that constant, + so it shifts both views equally and the *gap* is invariant — but + the absolute numbers both produce would scale linearly with any + change. Worth flagging if telemetry from PR #12 ever provides + a basis for re-deriving the 1/26 figure. +- **Sensitivity to fixture topology**. A wider star or a deeper + chain would shift the absolute drops; the qualitative findings + about each knob's leverage should hold but the magnitudes would + change. + +## How to reproduce + +```bash +uv run python scripts/sensitivity_sweep.py +``` + +Output is deterministic. Re-run after any change to `pp_interop/ +config.py`'s defaults to refresh the numbers in this doc. diff --git a/research/pandapower/README.md b/research/pandapower/README.md index d673f39..b497b3f 100644 --- a/research/pandapower/README.md +++ b/research/pandapower/README.md @@ -36,6 +36,12 @@ Date of research: April 2026. Pandapower latest at time of writing: **3.4.0** power-systems theory walkthrough of why nopywer and pandapower disagree, and why the disagreement runs in opposite directions on different fixtures (max-phase rule vs constant-power feedback). +9. [`09_sensitivity_to_config_assumptions.md`](09_sensitivity_to_config_assumptions.md) — + how much each `pp_interop/config.py` default actually moves the + numbers. Three sweeps (`X_OHM_PER_KM`, `PF`, `GEN_XDSS_PU`) with + tables and per-knob takeaways. Headline: X is negligible, PF is + modest, but X″d moves Isc by 2.2× across realistic generator + choices — worth overriding per-event. ## TL;DR From 4ea2057f1ea95fae9f969a6ed496e2144242ca43 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 22:22:46 +0200 Subject: [PATCH 08/14] refactor(constants): decompose RHO_COPPER into traceable components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The historical RHO_COPPER = 1/26 ("Rich's notes") bundled together three distinct effects that were previously opaque. Split them out without changing the numerical default: RHO_COPPER = RHO_COPPER_20C × k_temp(COPPER_TEMP_C) × FIELD_MARGIN ≈ (1/58) × 1.197 × 1.864 ≈ 0.0385 ≈ 1/26 Each component is now independently overridable: - RHO_COPPER_20C = 1/58 — annealed copper at 20 °C (IACS standard). Physics; not really tunable. - ALPHA_COPPER = 0.00393 — temperature coefficient at 20 °C. - COPPER_TEMP_C = 70 — assumed steady-state conductor T under festival load. Lower for cool events; raise for coiled-drum- in-the-sun scenarios. - FIELD_MARGIN = 1.864 — empirical catch-all for connector contact resistance, bundling, ageing, etc. Calibrated to reproduce the legacy 1/26 figure at the default temperature. The product at the defaults reproduces 1/26 to within 0.02 %, well below modelling noise. All 51 tests pass. One test (`test_to_pandapower_topology`) was hardcoding `1000/26` for the expected r_ohm_per_km; updated to use `RHO_COPPER` directly so it tracks the actual constant rather than the legacy literal. The "Rich's notes" provenance is preserved in the comment, framed as the historical origin of the figure rather than its current definition. --- src/nopywer/constants.py | 65 ++++++++++++++++++++++++++++++++++++---- tests/test_pp_interop.py | 5 +++- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/nopywer/constants.py b/src/nopywer/constants.py index f73b18e..63e8780 100644 --- a/src/nopywer/constants.py +++ b/src/nopywer/constants.py @@ -5,12 +5,65 @@ # (lighting, fridges, sound systems) PF = 0.9 -# Conservative resistance coefficient for field cable sizing. -# This intentionally overestimates copper-only resistance to provide margin -# under real-world deployment conditions. Has been found in Rich's old notes, -# probably comes from measurements they did. -# For reference, physical value is 1 / 58 ^^ -RHO_COPPER = 1 / 26 # [Ω·mm²/m] +# === Effective copper resistivity for field cable sizing === +# +# Conservative resistance coefficient. Intentionally overestimates +# copper-only resistance to provide margin under real-world deployment +# conditions. Historically a single opaque constant (`RHO_COPPER = 1/26`) +# documented as "from Rich's old notes — probably from measurements +# they did". The textbook physical value for annealed copper at 20 °C +# is ~1/58, so the historical 1/26 figure represents ≈ 2.2× textbook. +# +# Now decomposed into three independently-overridable components so the +# 2.2× inflation is traceable rather than opaque: +# +# RHO_COPPER = RHO_COPPER_20C × k_temp(COPPER_TEMP_C) × FIELD_MARGIN +# ≈ (1/58) × 1.197 × 1.864 +# ≈ 0.01724 × 1.197 × 1.864 +# ≈ 0.0385 ≈ 1/26 +# +# The product at the defaults reproduces the legacy 1/26 value to +# within 0.02 % — well below modelling noise — so test fixtures +# pinned to the old value continue to pass. +# +# Each component is independently overridable for sensitivity analysis +# or per-event tuning: +# +# - `RHO_COPPER_20C` is annealed copper at 20 °C (IACS standard). +# Not really tunable — physics. +# - `COPPER_TEMP_C` is the assumed steady-state conductor +# temperature under festival load. Lower for a known cool event; +# raise for a coiled-drum-in-the-sun scenario. +# - `FIELD_MARGIN` is a catch-all for connector contact resistance, +# bundling derating, ageing, strand breakage, and other real-world +# effects not modelled directly. Empirically calibrated; could be +# refined from measurement (e.g. PR #12 telemetry). + +# Annealed copper resistivity at 20 °C (IACS standard). +RHO_COPPER_20C = 1 / 58 # [Ω·mm²/m] + +# Temperature coefficient of copper at 20 °C. +ALPHA_COPPER = 0.00393 # [1/°C] + +# Assumed steady-state conductor temperature under festival load. +# 70 °C is reasonable for HO7RN-F flex under sustained near-rated +# current with ambient 20–30 °C. Coiled cables in direct sun can +# reach 90 °C and warrant overriding upward. +COPPER_TEMP_C = 70.0 # [°C] + +# Empirical field-margin multiplier on top of temperature-adjusted +# physics. Captures connector contact resistance, bundling, ageing, +# and other real-world effects not modelled directly. Calibrated to +# match the legacy "Rich's notes" RHO_COPPER = 1/26 at default +# COPPER_TEMP_C — independent measurement could refine this. +FIELD_MARGIN = 1.864 + +# Effective resistance coefficient — derived from physics + assumptions. +# Numerically ≈ 1/26 at the defaults (within 0.02 %); varies if a +# caller overrides COPPER_TEMP_C or FIELD_MARGIN. +RHO_COPPER = ( + RHO_COPPER_20C * (1 + ALPHA_COPPER * (COPPER_TEMP_C - 20)) * FIELD_MARGIN +) # [Ω·mm²/m] # Max acceptable voltage drop per NF C 15-100 / IEC 60364 VDROP_THRESHOLD_PERCENT = 5.0 diff --git a/tests/test_pp_interop.py b/tests/test_pp_interop.py index 4b00279..6180b98 100644 --- a/tests/test_pp_interop.py +++ b/tests/test_pp_interop.py @@ -44,6 +44,7 @@ pytest.importorskip("pandapower") +from nopywer.constants import RHO_COPPER from nopywer.models import Cable16A, Cable32A, PowerGrid, PowerNode from nopywer.pp_interop import ( PandapowerGrid, @@ -106,7 +107,9 @@ def test_to_pandapower_topology(): line = net.line.iloc[0] assert line["length_km"] == pytest.approx(0.05) - assert line["r_ohm_per_km"] == pytest.approx(1000 / 26 / 6.0) + # r_ohm_per_km = RHO_COPPER * 1000 / area_mm2; tracks the actual + # constant rather than its legacy 1/26 historical value. + assert line["r_ohm_per_km"] == pytest.approx(RHO_COPPER * 1000 / 6.0) assert line["max_i_ka"] == pytest.approx(0.032) From e2f47fb6700a9882afcc9fdeb7233406ff7722e7 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 22:29:54 +0200 Subject: [PATCH 09/14] fix: address pandapower interop review comments --- src/nopywer/pp_interop/__init__.py | 2 +- src/nopywer/pp_interop/_conversion.py | 24 +++++++++++++++++++----- src/nopywer/pp_interop/_powerflow.py | 2 -- src/nopywer/pp_interop/config.py | 10 ++++++++++ uv.lock | 12 ++++-------- 5 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/nopywer/pp_interop/__init__.py b/src/nopywer/pp_interop/__init__.py index 2fed97d..722a711 100644 --- a/src/nopywer/pp_interop/__init__.py +++ b/src/nopywer/pp_interop/__init__.py @@ -1,6 +1,6 @@ """Pandapower interop layer. -Optional feature — requires `pip install nopywer[pandapower]`. +Optional feature — requires `uv sync --extra pandapower`. Public API: to_pandapower(grid, ...) -> PandapowerGrid diff --git a/src/nopywer/pp_interop/_conversion.py b/src/nopywer/pp_interop/_conversion.py index 860fb61..105da7c 100644 --- a/src/nopywer/pp_interop/_conversion.py +++ b/src/nopywer/pp_interop/_conversion.py @@ -5,11 +5,9 @@ to the source `PowerGrid` for write-back. """ -from __future__ import annotations - import math from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from ..constants import PF, RHO_COPPER from ..models import PowerGrid @@ -17,6 +15,8 @@ if TYPE_CHECKING: from pandapower.auxiliary import pandapowerNet +else: + pandapowerNet = Any def _import_pandapower(): @@ -26,7 +26,7 @@ def _import_pandapower(): except ImportError as e: raise ImportError( "pandapower is required for this feature. " - "Install with: pip install nopywer[pandapower]" + "Install it in this project with: uv sync --extra pandapower" ) from e return pp, sc @@ -49,7 +49,7 @@ class PandapowerGrid: back onto its `PowerNode`s using `bus_idx`. """ - net: "pandapowerNet" + net: pandapowerNet bus_idx: dict[str, int] source: PowerGrid load_idx: dict[str, int] = field(default_factory=dict) @@ -68,6 +68,20 @@ def to_pandapower( Cables must already have `from_node` and `to_node` populated (set by `io.load_geojson` or by `analyze._snap_cables_to_nodes`). + Args: + grid: nopywer grid to convert. Its cables must already reference + existing node names via `from_node` and `to_node`. + gen_sn_kva: generator rated apparent power in kVA, used to derive + the short-circuit source strength. + gen_xdss_pu: generator subtransient reactance in per unit, used in + `s_sc_max_mva = (gen_sn_kva / 1000) / gen_xdss_pu`. + gen_rx: generator R/X ratio passed to pandapower's external-grid + short-circuit model. + x_ohm_per_km: cable reactance in ohms per kilometre for every + converted line. + load_pf: load power factor used to derive reactive power for + balanced AC power-flow loads. + The generator becomes an `ext_grid` with s_sc_max_mva = (gen_sn_kva / 1000) / gen_xdss_pu diff --git a/src/nopywer/pp_interop/_powerflow.py b/src/nopywer/pp_interop/_powerflow.py index a4156a5..e6768d4 100644 --- a/src/nopywer/pp_interop/_powerflow.py +++ b/src/nopywer/pp_interop/_powerflow.py @@ -11,8 +11,6 @@ to match `PowerNode.voltage`. """ -from __future__ import annotations - import math from dataclasses import dataclass diff --git a/src/nopywer/pp_interop/config.py b/src/nopywer/pp_interop/config.py index 78dde81..2c978ec 100644 --- a/src/nopywer/pp_interop/config.py +++ b/src/nopywer/pp_interop/config.py @@ -66,9 +66,16 @@ # === Generator (festival diesel default) === +# Rated generator apparent power in kVA. GEN_SN_KVA: float = 100.0 + +# Generator subtransient reactance in per unit. GEN_XDSS_PU: float = 0.12 + +# Generator resistance-to-reactance ratio. GEN_RX: float = 0.1 + +# Generator/slack bus voltage set point in per unit. GEN_VM_PU: float = 1.0 @@ -87,5 +94,8 @@ # === Fault calculation === +# IEC 60909 fault type: balanced three-phase short circuit. FAULT_TYPE: str = "3ph" + +# IEC 60909 case: maximum prospective short-circuit current. FAULT_CASE: str = "max" diff --git a/uv.lock b/uv.lock index 02b057c..e1bf313 100644 --- a/uv.lock +++ b/uv.lock @@ -301,7 +301,7 @@ requires-dist = [ { name = "numpy", specifier = ">=1.26" }, { name = "numpy", marker = "extra == 'modern'", specifier = ">=2.4" }, { name = "odfpy", specifier = ">=1.4" }, - { name = "pandapower", marker = "extra == 'pandapower'", specifier = ">=3.4" }, + { name = "pandapower", marker = "extra == 'pandapower'", git = "https://github.com/e2nIEE/pandapower.git?rev=472d8294be2ad56fdb62e439046e802609478bd1" }, { name = "pandas", specifier = ">=2.3" }, { name = "pandas", marker = "extra == 'modern'", specifier = ">=3.0" }, { name = "pyproj", specifier = ">=3.7" }, @@ -513,7 +513,7 @@ wheels = [ [[package]] name = "pandapower" version = "3.4.0" -source = { registry = "https://pypi.org/simple" } +source = { git = "https://github.com/e2nIEE/pandapower.git?rev=472d8294be2ad56fdb62e439046e802609478bd1#472d8294be2ad56fdb62e439046e802609478bd1" } dependencies = [ { name = "deepdiff" }, { name = "geojson" }, @@ -526,10 +526,6 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/cd/e7ba8e484505b1af0595b3ce88e12ea32ceea092e483dddc273b0ecd73aa/pandapower-3.4.0.tar.gz", hash = "sha256:bad4e267603fe3ea23e01d9d318d86572ff037cf4968693a851c91b2bb45b8da", size = 5223544, upload-time = "2026-02-09T15:00:09.96Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/d0/048318e7fca4a67fdd89c57b2ac02bc8450ad6ca7a7f738968bec0844ae9/pandapower-3.4.0-py3-none-any.whl", hash = "sha256:8ee08fe37039dac0be6c3469cb01e9ea3e90d900f44ee7f65ae45e4beabd3676", size = 5531008, upload-time = "2026-02-09T15:00:08.026Z" }, -] [[package]] name = "pandas" @@ -599,8 +595,8 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'extra-7-nopywer-modern' or extra != 'extra-7-nopywer-pandapower'" }, + { name = "python-dateutil", marker = "extra == 'extra-7-nopywer-modern' or extra != 'extra-7-nopywer-pandapower'" }, { name = "tzdata", marker = "(sys_platform == 'emscripten' and extra == 'extra-7-nopywer-modern') or (sys_platform == 'emscripten' and extra != 'extra-7-nopywer-pandapower') or (sys_platform == 'win32' and extra == 'extra-7-nopywer-modern') or (sys_platform == 'win32' and extra != 'extra-7-nopywer-pandapower') or (extra == 'extra-7-nopywer-modern' and extra == 'extra-7-nopywer-pandapower')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } From 52d4018db1f9cc762964189f9b66c4a9b4c6bd6f Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 22:31:15 +0200 Subject: [PATCH 10/14] fix: satisfy ruff after pp interop comparison merge --- tests/test_pp_interop_parity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_pp_interop_parity.py b/tests/test_pp_interop_parity.py index 347a2fd..0256dda 100644 --- a/tests/test_pp_interop_parity.py +++ b/tests/test_pp_interop_parity.py @@ -62,13 +62,13 @@ import pytest -pp = pytest.importorskip("pandapower") - from nopywer.analyze import _compute_voltage_drop from nopywer.constants import V0 from nopywer.models import Cable, PowerGrid, PowerNode from nopywer.pp_interop import config +pp = pytest.importorskip("pandapower") + def test_voltage_drop_matches_pandapower_with_constant_current_load(): """nopywer == pandapower when the load is modelled as constant-current. From ac9a8ec1b2fc76aa06950ac8659c3e9953ee2a23 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 22:41:48 +0200 Subject: [PATCH 11/14] ci: check ruff formatting --- .github/workflows/ci.yml | 1 + scripts/sensitivity_sweep.py | 2 +- scripts/validate_optimiser_with_runpp.py | 26 +++++++++++++----------- src/nopywer/constants.py | 4 +--- src/nopywer/pp_interop/_powerflow.py | 8 ++------ tests/test_pp_interop_topology.py | 9 +++----- 6 files changed, 22 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5175f05..9f39dec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: enable-cache: true - run: uv sync --frozen - run: uv run ruff check . --verbose + - run: uv run ruff format --check . test: runs-on: ubuntu-latest diff --git a/scripts/sensitivity_sweep.py b/scripts/sensitivity_sweep.py index 7ce44ee..e4b893e 100644 --- a/scripts/sensitivity_sweep.py +++ b/scripts/sensitivity_sweep.py @@ -163,7 +163,7 @@ def sweep_gen_xdss_pu() -> None: print("== GEN_XDSS_PU sweep (default 0.12, ±range 0.08–0.18) ==") print("fixture: 1 cable, 50 m / 32 A, 3 kW load") print("metric: Isc at the generator bus, IEC 60909 3-phase max-case") - print(f"{'X''d pu':>10} {'Isc gen kA':>12} {'Isc load kA':>13}") + print(f"{'Xd pu':>10} {'Isc gen kA':>12} {'Isc load kA':>13}") print("-" * 39) base_xdss = 0.12 for xdss in _frange(0.08, 0.18, 0.01): diff --git a/scripts/validate_optimiser_with_runpp.py b/scripts/validate_optimiser_with_runpp.py index 75aed24..218984a 100644 --- a/scripts/validate_optimiser_with_runpp.py +++ b/scripts/validate_optimiser_with_runpp.py @@ -26,17 +26,17 @@ def main() -> None: print(f"Loaded {len(nodes)} nodes from {FIXTURE.name}") grid = optimize_layout(PowerGrid(nodes=nodes, cables={})) - print(f"Optimised: {len(grid.cables)} cables, " - f"{sum(c.length_m for c in grid.cables.values()):.0f} m total") + print( + f"Optimised: {len(grid.cables)} cables, " + f"{sum(c.length_m for c in grid.cables.values()):.0f} m total" + ) diff = compare_with_tree_walk(grid) print(f"AC converged: {diff.converged}") print("\n=== Voltage drop comparison (% at each node) ===") print(f"{'node':<32} {'tree':>8} {'AC':>8} {'Δ':>8}") - rows = sorted( - diff.bus_vdrop_percent.items(), key=lambda kv: kv[1][1], reverse=True - ) + rows = sorted(diff.bus_vdrop_percent.items(), key=lambda kv: kv[1][1], reverse=True) for name, (tw, ac, delta) in rows[:15]: print(f"{name[:32]:<32} {tw:>8.2f} {ac:>8.2f} {delta:>+8.3f}") @@ -49,9 +49,7 @@ def main() -> None: print("\n=== Cable current comparison (top 10 by AC current) ===") print(f"{'cable':<14} {'tree_max_A':>12} {'ac_A':>10} {'Δ A':>8}") - rows = sorted( - diff.line_current_a.items(), key=lambda kv: kv[1][1], reverse=True - ) + rows = sorted(diff.line_current_a.items(), key=lambda kv: kv[1][1], reverse=True) for cid, (tw, ac, delta) in rows[:10]: print(f"{cid[:14]:<14} {tw:>12.1f} {ac:>10.1f} {delta:>+8.2f}") @@ -66,11 +64,15 @@ def main() -> None: voltage_deltas = [d for _, _, d in diff.bus_voltage_v.values()] current_deltas = [d for _, _, d in diff.line_current_a.values()] if voltage_deltas: - print(f"Voltage Δ: max |Δ| = {max(abs(d) for d in voltage_deltas):.2f} V, " - f"mean |Δ| = {sum(abs(d) for d in voltage_deltas) / len(voltage_deltas):.3f} V") + print( + f"Voltage Δ: max |Δ| = {max(abs(d) for d in voltage_deltas):.2f} V, " + f"mean |Δ| = {sum(abs(d) for d in voltage_deltas) / len(voltage_deltas):.3f} V" + ) if current_deltas: - print(f"Current Δ: max |Δ| = {max(abs(d) for d in current_deltas):.2f} A, " - f"mean |Δ| = {sum(abs(d) for d in current_deltas) / len(current_deltas):.3f} A") + print( + f"Current Δ: max |Δ| = {max(abs(d) for d in current_deltas):.2f} A, " + f"mean |Δ| = {sum(abs(d) for d in current_deltas) / len(current_deltas):.3f} A" + ) if __name__ == "__main__": diff --git a/src/nopywer/constants.py b/src/nopywer/constants.py index 63e8780..67e1878 100644 --- a/src/nopywer/constants.py +++ b/src/nopywer/constants.py @@ -61,9 +61,7 @@ # Effective resistance coefficient — derived from physics + assumptions. # Numerically ≈ 1/26 at the defaults (within 0.02 %); varies if a # caller overrides COPPER_TEMP_C or FIELD_MARGIN. -RHO_COPPER = ( - RHO_COPPER_20C * (1 + ALPHA_COPPER * (COPPER_TEMP_C - 20)) * FIELD_MARGIN -) # [Ω·mm²/m] +RHO_COPPER = RHO_COPPER_20C * (1 + ALPHA_COPPER * (COPPER_TEMP_C - 20)) * FIELD_MARGIN # [Ω·mm²/m] # Max acceptable voltage drop per NF C 15-100 / IEC 60364 VDROP_THRESHOLD_PERCENT = 5.0 diff --git a/src/nopywer/pp_interop/_powerflow.py b/src/nopywer/pp_interop/_powerflow.py index e6768d4..b76ffc5 100644 --- a/src/nopywer/pp_interop/_powerflow.py +++ b/src/nopywer/pp_interop/_powerflow.py @@ -96,17 +96,13 @@ class TreeWalkVsAcDiff: def worst_voltage_disagreement(self) -> tuple[str, float] | None: if not self.bus_voltage_v: return None - name, (_, _, delta) = max( - self.bus_voltage_v.items(), key=lambda kv: abs(kv[1][2]) - ) + name, (_, _, delta) = max(self.bus_voltage_v.items(), key=lambda kv: abs(kv[1][2])) return name, delta def worst_current_disagreement(self) -> tuple[str, float] | None: if not self.line_current_a: return None - cid, (_, _, delta) = max( - self.line_current_a.items(), key=lambda kv: abs(kv[1][2]) - ) + cid, (_, _, delta) = max(self.line_current_a.items(), key=lambda kv: abs(kv[1][2])) return cid, delta diff --git a/tests/test_pp_interop_topology.py b/tests/test_pp_interop_topology.py index 269fee8..a9bb9ae 100644 --- a/tests/test_pp_interop_topology.py +++ b/tests/test_pp_interop_topology.py @@ -163,8 +163,7 @@ def test_deep_linear_chain_compounds_drop_with_depth(): _, _, gap_a = diff.bus_vdrop_percent["a"] _, _, gap_d = diff.bus_vdrop_percent["d"] assert gap_d > gap_a, ( - f"expected gap to grow with depth, got " - f"gap@a={gap_a:.3f} %, gap@d={gap_d:.3f} %" + f"expected gap to grow with depth, got gap@a={gap_a:.3f} %, gap@d={gap_d:.3f} %" ) @@ -226,12 +225,10 @@ def test_wide_star_branches_are_independent(): leaf_drops_tw = [diff.bus_vdrop_percent[f"stage_{i + 1}"][0] for i in range(5)] leaf_drops_ac = [diff.bus_vdrop_percent[f"stage_{i + 1}"][1] for i in range(5)] assert max(leaf_drops_tw) - min(leaf_drops_tw) < 0.05, ( - f"tree walk should report identical drops on symmetric branches; " - f"got {leaf_drops_tw}" + f"tree walk should report identical drops on symmetric branches; got {leaf_drops_tw}" ) assert max(leaf_drops_ac) - min(leaf_drops_ac) < 0.05, ( - f"AC should report identical drops on symmetric branches; " - f"got {leaf_drops_ac}" + f"AC should report identical drops on symmetric branches; got {leaf_drops_ac}" ) # Branch independence: AC and tree walk agree to within 0.5 pp on From fdaaf17b025f32eb7da2489071764cd50c42b0b0 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 22:51:03 +0200 Subject: [PATCH 12/14] docs(research): three-phase asymmetric modelling shopping list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents what would be needed to move from balanced runpp to asymmetric runpp_3ph — the right fix for mechanism 2 (the unbalanced single-phase divergence captured in 08). Three categories of input pandapower needs: 1. Per-load phase assignment (already in PowerNode.phase, just not always populated in fixtures) 2. Per-cable zero-sequence params (r0, x0, c0 — engineering defaults of r0/r1 ≈ x0/x1 ≈ 4 for 4-core LV flex) 3. Source vector group + zero-sequence source impedance (most festival diesel gen-sets are TN-S star-grounded, Yn) Probed the existing fixtures for what's already there: - input_nodes.geojson (2025 production fixture, 51 nodes): every load has phase=None. Modelled balanced not because the festival was balanced but because the design doc was created before phase assignment was decided. - analyze_input.geojson (small synthetic): has phase=1, phase=2 on its two loads. Could be modelled three-phase today. Three strategies for filling in the missing 2025-fixture phase data: historical reconstruction (most realistic, requires field records), round-robin synthesis (cheap and reproducible — the recommended default), or all-on-L1 worst-case stress. Implementation cost estimate: ~5 hours total across data, config, code, tests, and findings doc. Would land as feat/pandapower-runpp-3ph stacked on this PR. Updates README index to include doc 10. --- .../10_three_phase_asymmetric_modelling.md | 355 ++++++++++++++++++ research/pandapower/README.md | 8 + 2 files changed, 363 insertions(+) create mode 100644 research/pandapower/10_three_phase_asymmetric_modelling.md diff --git a/research/pandapower/10_three_phase_asymmetric_modelling.md b/research/pandapower/10_three_phase_asymmetric_modelling.md new file mode 100644 index 0000000..e80e080 --- /dev/null +++ b/research/pandapower/10_three_phase_asymmetric_modelling.md @@ -0,0 +1,355 @@ +# Three-phase asymmetric modelling — what would it take? + +The single biggest source of disagreement between nopywer's tree +walk and our pandapower converter is **mechanism 2**: unbalanced +single-phase loads. Captured in detail in +[`08_why_tree_walk_and_ac_disagree.md`](./08_why_tree_walk_and_ac_disagree.md); +the short version is that nopywer's `max(I_per_phase)` rule +*over*-states drop on lightly-loaded phases, and our converter's +balanced-load collapse *under*-states it on heavily-loaded ones. +Both are wrong, in opposite directions. Neither tool models the +neutral conductor at all. + +The right fix is `pp.runpp_3ph` with `pp.create_asymmetric_load`, +listed as opportunity §3 in +[`06_extended_opportunities.md`](./06_extended_opportunities.md). +This doc is the detailed shopping list for that fix: what data +pandapower needs, what's already in the existing fixtures, what's +missing, and how much work it is to bridge the gap. + +## What pandapower's `runpp_3ph` needs + +Three categories of input that the current converter either +discards or doesn't see at all. + +### 1. Per-load phase assignment + +Each single-phase load needs to declare which of L1, L2, L3 it +connects to. Three cases pandapower distinguishes: + +```python +# Single-phase on L1 only: +pp.create_asymmetric_load( + net, bus=..., p_a_mw=p, p_b_mw=0, p_c_mw=0, +) + +# Three-phase balanced (motor, 3P distro): +pp.create_load(net, bus=..., p_mw=p) # what we use today + +# Three-phase unbalanced (rare in practice): +pp.create_asymmetric_load( + net, bus=..., p_a_mw=p1, p_b_mw=p2, p_c_mw=p3, +) +``` + +nopywer's `PowerNode.phase` already carries this: + +- `1`, `2`, `3` → single-phase load on L1/L2/L3 +- `None` → balanced (or "to be assigned later") +- `"U"`, `"Y"` → sub-grid markers used for reporting in + `print_grid_info`; no electrical meaning in `analyze.py` + +So the per-load phase information **already exists in nopywer's +data model**. The question is whether it's populated in the +fixtures we want to validate against. + +### 2. Per-cable zero-sequence parameters + +Pandapower's `r_ohm_per_km`, `x_ohm_per_km`, `c_nf_per_km` are +**positive-sequence** values — they describe what current sees in +the symmetric balanced case. Asymmetric flow also needs +**zero-sequence** equivalents: + +```python +pp.create_line_from_parameters( + net, ..., + r_ohm_per_km=..., r0_ohm_per_km=..., # zero-sequence R + x_ohm_per_km=..., x0_ohm_per_km=..., # zero-sequence X + c_nf_per_km=..., c0_nf_per_km=..., # zero-sequence C +) +``` + +The zero-sequence params describe how the cable behaves to **the +imbalance current returning through the neutral and earth**. They +have no nopywer analogue (the tree walk doesn't model neutral at +all) and aren't on cable spec sheets for festival flex (HO7RN-F, +Titanex). Engineering rule-of-thumb defaults for 4-core LV flex +with full-section neutral: + +``` + r0 / r1 ≈ 3 to 5 (neutral-and-earth return path R is ~4× phase R) + x0 / x1 ≈ 3 to 5 (loop area of zero-sequence path is bigger) +``` + +These would live as **global defaults in `pp_interop/config.py`**, +the same way `X_OHM_PER_KM = 0.08` is today. Future per-cable +overrides could come from telemetry calibration (PR #12 work) or +direct measurement. + +### 3. Source vector group and zero-sequence impedance + +The festival generator's neutral handling determines whether the +zero-sequence current can flow back through the source: + +```python +# Today (balanced runpp): +pp.create_ext_grid( + net, bus=..., vm_pu=1.0, s_sc_max_mva=..., rx_max=..., +) + +# For runpp_3ph, additionally: +pp.create_ext_grid( + net, bus=..., ..., + x0x_max=..., # ratio X0 / X1 for source + r0x0_max=..., # ratio R0 / X0 for source +) +``` + +Most festival diesel gen-sets are **TN-S star with grounded +neutral** (Yn). The neutral provides a return path for +zero-sequence current. Typical defaults: + +``` + x0x_max ≈ 1.0 + r0x0_max ≈ 0.1 +``` + +Some hire kit is **IT** (isolated neutral) — no zero-sequence +return at all, very different fault behaviour. A +`SOURCE_NEUTRAL_GROUNDING` flag in config would let the planner +override. + +## Does the existing fixture contain the info? + +Two production fixtures, two stories. + +### `tests/fixtures/input_nodes.geojson` — the 2025 production fixture + +**Probed it directly:** + +``` +Total features: 54 +Property keys observed: ['name', 'power', 'type'] +Phase histogram: + phase=None n=54 +``` + +Every node has `phase=None`. `io.load_geojson` then splits each +load evenly across the three phases (`power_per_phase += power/3`), +making every load **balanced by default**. + +So the 2025 fixture is **modelled balanced not because the festival +was balanced** but because **the design document was created +before phase assignment was decided**. In reality those 51 loads +would each be wired to one specific phase at deployment. + +**Implication**: validating runpp_3ph against this fixture as-is +would tell us nothing new — every load is balanced, so the +asymmetric and balanced solvers should agree. The fixture has to +be enriched (see below) before it can exercise the asymmetric +path meaningfully. + +### `tests/fixtures/analyze_input.geojson` — the small synthetic + +**Phase info is there:** + +```json +{ "name": "Load A", "power": 3000.0, "phase": 1 } +{ "name": "Load B", "power": 6000.0, "phase": 2 } +``` + +This fixture could be modelled three-phase **directly today**. +It's exactly the fixture that exposed mechanism 2 (the unbalanced +single-phase divergence in +[`08_why_tree_walk_and_ac_disagree.md`](./08_why_tree_walk_and_ac_disagree.md)) +and is the natural first target for `runpp_3ph` validation. + +### What's in NO fixture + +- Zero-sequence cable parameters. None of the GeoJSON properties + carry cable type beyond `area` and `plugs&sockets`. +- Source vector group / earthing scheme. The generator is just + a Point with `power=0`. +- Per-load *measured* phase (vs *designed* phase). Festival + electricians sometimes deviate from the plan based on what's + available on site. + +These gaps are filled by **assumed defaults in +`pp_interop/config.py`** rather than per-feature properties. +Same pattern as `X_OHM_PER_KM = 0.08` today — global, configurable, +documented. + +## Filling in the missing per-load phase + +The 2025 fixture has 54 nodes with no phase assignment. Three +strategies for adding phase data, ordered from realistic to +worst-case: + +### Strategy A — historical reconstruction + +Go back to the 2025 event records (deployed phase plan, electrician's +notes, distro labels) and add `phase: 1`/`2`/`3` to each load. +Most realistic — captures what the real grid looked like — but +requires field knowledge that lives outside the codebase. + +### Strategy B — round-robin synthesis + +Assign phase by node order: `phase = (i % 3) + 1`. Sequence the +54 loads cyclically across L1, L2, L3. Approximates what a +careful planner would do (alternate phases as you go down a +corridor) and produces a roughly balanced grid. + +```python +for i, (name, node) in enumerate(grid.nodes.items()): + if not node.is_generator: + node.phase = (i % 3) + 1 +``` + +Realistic enough for testing; fully reproducible. **My +recommendation as the default.** + +### Strategy C — worst-case stress + +Assign every load to `phase=1`. Maximises imbalance — useful as +a stress test for the solver and for showing "this is what bad +phase planning looks like". A planner who runs this on a real +fixture and sees catastrophic neutral currents has empirical +evidence for why phase balancing matters. + +## Implementation shopping list + +Rough estimate of the work, in three layers: + +### Layer 1 — Data / fixtures + +- `analyze_input.geojson` — already has `phase` on each load. **No work.** +- `input_nodes.geojson` — needs phase assignment. **Strategy B (round-robin) + via a one-shot script** is the cheapest realistic option. ~5 lines. +- New synthetic fixtures specifically for asymmetric stress (e.g. all + loads on L1) could live alongside the existing tests. ~30 lines per + fixture. + +### Layer 2 — Config defaults + +Add to `pp_interop/config.py`: + +```python +# === Three-phase asymmetric flow === + +# Zero-sequence cable parameter ratios. Typical for 4-core LV flex +# with full-section neutral. +R0_OVER_R1 = 4.0 +X0_OVER_X1 = 4.0 + +# Source neutral grounding. Most festival diesel gen-sets are +# TN-S with solidly grounded star (Yn). +SOURCE_X0X_MAX = 1.0 +SOURCE_R0X0_MAX = 0.1 +``` + +### Layer 3 — Code + +In `pp_interop/_conversion.py`, branch on `node.phase`: + +```python +for name, node in grid.nodes.items(): + if node.is_generator or node.power_watts <= 0: + continue + p_mw = node.power_watts / 1e6 + q_mvar = p_mw * tan_phi + + if isinstance(node.phase, int) and 1 <= node.phase <= 3: + # Asymmetric: all power on one phase + p_a, p_b, p_c = (p_mw if node.phase == i + 1 else 0.0 for i in range(3)) + q_a, q_b, q_c = (q_mvar if node.phase == i + 1 else 0.0 for i in range(3)) + pp.create_asymmetric_load( + net, bus=..., p_a_mw=p_a, p_b_mw=p_b, p_c_mw=p_c, + q_a_mvar=q_a, q_b_mvar=q_b, q_c_mvar=q_c, + ) + else: + # Truly balanced (or unphased — assumed balanced) + pp.create_load(net, bus=..., p_mw=p_mw, q_mvar=q_mvar) +``` + +Plus the `r0/x0` params on every line, `x0x_max/r0x0_max` on the +ext_grid, and a new entry point `compute_power_flow_3ph(pp_grid)` +that calls `pp.runpp_3ph` instead of `runpp`. + +### Estimated cost + +| Layer | Lines | Time | +|---|---:|---:| +| Add phase to `analyze_input.geojson` (already done) | 0 | 0 | +| Round-robin phase synthesis script for `input_nodes.geojson` | ~5 | 15 min | +| New config defaults (zero-sequence + source vector group) | ~10 | 15 min | +| Branch on phase in `_conversion.py` | ~30 | 1 hr | +| New `compute_power_flow_3ph` function | ~50 | 1 hr | +| Tests (parity + comparison + topology), mirroring the existing balanced suite | ~150 | 2 hr | +| Findings doc (`11_runpp_3ph_findings.md`) | ~200 | 1 hr | + +**~5 hours total.** The biggest unknown is whether `runpp_3ph` +converges cleanly on the 2025-scale fixture (51 nodes) given its +documented sensitivity, and whether round-robin phase synthesis +produces results meaningful enough to compare against the +historical balanced answer. + +## What this would tell us + +Three things we currently don't know: + +### 1. The real shape of mechanism 2 at scale + +On `analyze_input` (2 loads, 1 cable per load) it's a ~5 pp gap. +On `input_nodes` (51 loads, optimised tree) it could be anywhere +— and the gap shape is exactly what Vincent's been asking about +indirectly. Today we report only the balanced-flow numbers; the +asymmetric numbers might be very different. + +### 2. Neutral currents per cable + +Critical for **cable-spec decisions**: a `4G6` cable (4 cores +all 6 mm²) handles a fully-loaded neutral; a `3G6+½N` (reduced- +section neutral) does not. Currently no festival design tool I'm +aware of surfaces this. If round-robin phase synthesis + runpp_3ph +shows the worst neutral current is < 50 % of the worst phase +current, reduced-N cables are fine. If it shows neutral ≈ phase, +the planner needs full-section-N specs. + +### 3. Whether nopywer's max-phase rule is actually conservative + +The current assumption is "max-phase always over-states the worst +phase". For symmetric distributions and simple topologies that +holds. For specific bad phase distributions it might *under*-state +— if all the constant-power loads on the heavy phase pull each +other's voltage down through compounding feedback, the actual +worst-phase drop could exceed nopywer's first-pass estimate. The +only way to know is to measure with `runpp_3ph`. + +## What this would NOT tell us + +- **Whether the 2025 grid was actually unbalanced.** The fixture + has no phase data. Round-robin synthesis is a *plausible* + reconstruction, not a measurement. +- **What real zero-sequence cable parameters are.** The defaults + are engineering rule-of-thumb; calibration would need + measured-vs-modelled telemetry from a deployed event. +- **Whether the source neutral is actually grounded.** That's a + hire-contract / electrician question, not a model question. The + default assumption (`Yn`) covers most cases but isn't universal. + +## Where it slots in + +If pursued, this work would land as: + +- A new branch off `feat/pandapower-comparison-tests` (the current + PR stack head): `feat/pandapower-runpp-3ph`. +- A new module file `pp_interop/_powerflow_3ph.py` mirroring + `_powerflow.py`'s shape. +- A new findings doc `11_runpp_3ph_findings.md` capturing the + numbers — same register as + [`07_optimiser_validation_findings.md`](./07_optimiser_validation_findings.md). +- Extension of `scripts/sensitivity_sweep.py` to add a fourth + sweep over the new zero-sequence ratio defaults. + +The conversion layer (`to_pandapower`) and short-circuit module +stay backward-compatible — `runpp_3ph` is purely additive. diff --git a/research/pandapower/README.md b/research/pandapower/README.md index b497b3f..5a94bea 100644 --- a/research/pandapower/README.md +++ b/research/pandapower/README.md @@ -42,6 +42,14 @@ Date of research: April 2026. Pandapower latest at time of writing: **3.4.0** tables and per-knob takeaways. Headline: X is negligible, PF is modest, but X″d moves Isc by 2.2× across realistic generator choices — worth overriding per-event. +10. [`10_three_phase_asymmetric_modelling.md`](10_three_phase_asymmetric_modelling.md) — + detailed shopping list for moving from balanced `runpp` to + asymmetric `runpp_3ph`. Covers what pandapower needs (per-load + phase, zero-sequence cable params, source vector group), what + the existing fixtures contain (the 2025 fixture has no phase + data; the small synthetic does), three strategies for + synthesising phase data, and a ~5-hour cost estimate for the + full implementation. ## TL;DR From 105d7d9b2fe643df4f95377d07a5baab5334712e Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 10 May 2026 22:53:41 +0200 Subject: [PATCH 13/14] docs(research): drop time-cost estimates Removes both kinds of time guesses across the research docs: - Implementation-effort estimates (hours/minutes per layer in doc 10's "Estimated cost" table, plus the "~5 hours total" summary). The per-layer LINE counts are kept since those are scope indicators, not effort guesses. Table renamed "Scope summary". - Reading-time estimates (per-doc and per-file minutes in REVIEWING.md, plus the "~25 minutes / ~15 more" preamble). The README's reference to doc 10's "5-hour cost estimate" is updated to "per-layer scope breakdown" to match. Effort guesses are usually wrong and tend to be quoted as if they were measurements; dropping them avoids anchoring future implementers to a number that has no basis. --- .../10_three_phase_asymmetric_modelling.md | 33 +++++++++---------- research/pandapower/README.md | 3 +- research/pandapower/REVIEWING.md | 23 +++++++------ 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/research/pandapower/10_three_phase_asymmetric_modelling.md b/research/pandapower/10_three_phase_asymmetric_modelling.md index e80e080..1cddf3b 100644 --- a/research/pandapower/10_three_phase_asymmetric_modelling.md +++ b/research/pandapower/10_three_phase_asymmetric_modelling.md @@ -275,23 +275,22 @@ Plus the `r0/x0` params on every line, `x0x_max/r0x0_max` on the ext_grid, and a new entry point `compute_power_flow_3ph(pp_grid)` that calls `pp.runpp_3ph` instead of `runpp`. -### Estimated cost - -| Layer | Lines | Time | -|---|---:|---:| -| Add phase to `analyze_input.geojson` (already done) | 0 | 0 | -| Round-robin phase synthesis script for `input_nodes.geojson` | ~5 | 15 min | -| New config defaults (zero-sequence + source vector group) | ~10 | 15 min | -| Branch on phase in `_conversion.py` | ~30 | 1 hr | -| New `compute_power_flow_3ph` function | ~50 | 1 hr | -| Tests (parity + comparison + topology), mirroring the existing balanced suite | ~150 | 2 hr | -| Findings doc (`11_runpp_3ph_findings.md`) | ~200 | 1 hr | - -**~5 hours total.** The biggest unknown is whether `runpp_3ph` -converges cleanly on the 2025-scale fixture (51 nodes) given its -documented sensitivity, and whether round-robin phase synthesis -produces results meaningful enough to compare against the -historical balanced answer. +### Scope summary + +| Layer | Lines | +|---|---:| +| Add phase to `analyze_input.geojson` (already done) | 0 | +| Round-robin phase synthesis script for `input_nodes.geojson` | ~5 | +| New config defaults (zero-sequence + source vector group) | ~10 | +| Branch on phase in `_conversion.py` | ~30 | +| New `compute_power_flow_3ph` function | ~50 | +| Tests (parity + comparison + topology), mirroring the existing balanced suite | ~150 | +| Findings doc (`11_runpp_3ph_findings.md`) | ~200 | + +The biggest unknown is whether `runpp_3ph` converges cleanly on +the 2025-scale fixture (51 nodes) given its documented sensitivity, +and whether round-robin phase synthesis produces results meaningful +enough to compare against the historical balanced answer. ## What this would tell us diff --git a/research/pandapower/README.md b/research/pandapower/README.md index 5a94bea..2f0a017 100644 --- a/research/pandapower/README.md +++ b/research/pandapower/README.md @@ -48,8 +48,7 @@ Date of research: April 2026. Pandapower latest at time of writing: **3.4.0** phase, zero-sequence cable params, source vector group), what the existing fixtures contain (the 2025 fixture has no phase data; the small synthetic does), three strategies for - synthesising phase data, and a ~5-hour cost estimate for the - full implementation. + synthesising phase data, and a per-layer scope breakdown. ## TL;DR diff --git a/research/pandapower/REVIEWING.md b/research/pandapower/REVIEWING.md index 33daead..eed3cd0 100644 --- a/research/pandapower/REVIEWING.md +++ b/research/pandapower/REVIEWING.md @@ -1,7 +1,6 @@ # Reviewing this branch -A short guide for whoever picks this up. Reading the docs in order -takes ~25 minutes. Reading the code takes ~15 more. +A short guide for whoever picks this up. ## What changed at a glance @@ -21,31 +20,31 @@ Total: 6 new docs, 6 new code files, 2 modified files. **Skip nothing.** The docs build on each other. 1. [`research/pandapower/README.md`](./README.md) — TL;DR, sets the - context for why this branch exists. **2 min.** + context for why this branch exists. 2. [`02_nopywer_power_calculations.md`](./02_nopywer_power_calculations.md) — what nopywer's tree walk actually does. Skim if you're - already familiar with `analyze.py`. **5 min.** + already familiar with `analyze.py`. 3. [`03_intersection.md`](./03_intersection.md) — concept-by-concept mapping nopywer ↔ pandapower. Useful to understand the choices - in the conversion layer. **5 min.** + in the conversion layer. 4. [`06_extended_opportunities.md`](./06_extended_opportunities.md) — the menu of pandapower-able calculations. Reading this first - makes the next doc make sense. **5 min.** + makes the next doc make sense. 5. [`07_optimiser_validation_findings.md`](./07_optimiser_validation_findings.md) — what was actually built (opportunity §1) and what running it on - the 2025 fixture revealed. Has the diagrams. **8 min.** + the 2025 fixture revealed. Has the diagrams. After that, read the code in this order: 6. `src/nopywer/pp_interop/config.py` — every assumption / default - in one file with explanations. **3 min.** + in one file with explanations. 7. `src/nopywer/pp_interop/_conversion.py` — the only file that - touches pandapower's `create_*` API. Worth scrutinising. **5 min.** + touches pandapower's `create_*` API. Worth scrutinising. 8. `src/nopywer/pp_interop/_powerflow.py` and `_shortcircuit.py` - — thin wrappers over `runpp` and `calc_sc`. **3 min each.** -9. `tests/test_pp_interop.py` — exercises the public API. **3 min.** + — thin wrappers over `runpp` and `calc_sc`. +9. `tests/test_pp_interop.py` — exercises the public API. 10. `scripts/validate_optimiser_with_runpp.py` — end-to-end runner; - its output is the headline finding. **2 min.** + its output is the headline finding. Skip on first pass: `__init__.py` (re-exports only), older research docs `01`, `04`, `05` (background; not required to review the From bc2f85c6a80be7a3b7204f8a0078200dedfce326 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Fri, 15 May 2026 18:47:56 +0200 Subject: [PATCH 14/14] style: format pandapower timing changes --- scripts/validate_optimiser_with_runpp.py | 2 -- src/nopywer/pp_interop/_powerflow.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/validate_optimiser_with_runpp.py b/scripts/validate_optimiser_with_runpp.py index e3495fa..c312fba 100644 --- a/scripts/validate_optimiser_with_runpp.py +++ b/scripts/validate_optimiser_with_runpp.py @@ -68,8 +68,6 @@ def main() -> None: print(f"AC: {diff.time_diff[1]:.3f} ms") print(f"diff: {abs(diff.time_diff[2]):.3f} ms") - - print("\n=== Summary ===") voltage_deltas = [d for _, _, d in diff.bus_voltage_v.values()] current_deltas = [d for _, _, d in diff.line_current_a.values()] diff --git a/src/nopywer/pp_interop/_powerflow.py b/src/nopywer/pp_interop/_powerflow.py index 31e50a2..7620896 100644 --- a/src/nopywer/pp_interop/_powerflow.py +++ b/src/nopywer/pp_interop/_powerflow.py @@ -133,7 +133,7 @@ def compare_with_tree_walk(grid: PowerGrid, **to_pp_kwargs) -> TreeWalkVsAcDiff: ac = compute_power_flow(pp_grid) end = time.time() time_ac = end - start - time_diff = (1e3*time_tw, 1e3*time_ac, 1e3*(time_tw-time_ac)) + time_diff = (1e3 * time_tw, 1e3 * time_ac, 1e3 * (time_tw - time_ac)) bus_voltage_v: dict[str, tuple[float, float, float]] = {} bus_vdrop_percent: dict[str, tuple[float, float, float]] = {}