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/research/pandapower/02_nopywer_power_calculations.md b/research/pandapower/02_nopywer_power_calculations.md index 24c8269..e492260 100644 --- a/research/pandapower/02_nopywer_power_calculations.md +++ b/research/pandapower/02_nopywer_power_calculations.md @@ -14,7 +14,7 @@ form factors (16 A / 32 A / 63 A / 125 A). A GeoJSON FeatureCollection containing: - **Point features** → loads or the generator. Properties: `name`, `power` - (watts), `phase` (1, 2, 3, "U", "Y", or null). The generator is identified + (watts), `phase` (1, 2, 3, or null). The generator is identified by the substring `"generator"` in its name. - **LineString features** → cables with the two coordinate endpoints. Properties: `length` (m, optional — geodesic distance is used otherwise), diff --git a/research/pandapower/03_intersection.md b/research/pandapower/03_intersection.md index 84693ac..0d778f4 100644 --- a/research/pandapower/03_intersection.md +++ b/research/pandapower/03_intersection.md @@ -7,7 +7,7 @@ nopywer concepts have a direct pandapower analogue and which do not. | nopywer concept | pandapower analogue | Notes / friction | |---|---|---| -| `PowerNode` (load) | `net.load` (balanced) or `net.asymmetric_load` (per-phase) | Loads with `phase ∈ {1,2,3}` would need `asymmetric_load` with `p_a_mw / p_b_mw / p_c_mw`. nopywer's "U" / "Y" sub-grid markers have no pandapower analogue. | +| `PowerNode` (load) | `net.load` (balanced) or `net.asymmetric_load` (per-phase) | Loads with `phase ∈ {1,2,3}` would need `asymmetric_load` with `p_a_mw / p_b_mw / p_c_mw`. Non-numeric or missing phase data has no pandapower analogue and should be treated as unassigned. | | `PowerNode` (generator) | `net.ext_grid` (slack bus) | Pandapower needs a slack reference. The generator has no internal impedance in nopywer — would map to a `vm_pu = 1.0` `ext_grid` on a dedicated bus. | | `Cable` | `net.line` | The bridge requires R, X, C, max-I per km — see "Cable model gap" below. | | `Cable.length_m` | `line.length_km` | Straight conversion `× 1e-3`. Pandapower forbids zero-length lines. | diff --git a/research/pandapower/04_porting_considerations.md b/research/pandapower/04_porting_considerations.md index dc3a8f5..84a0b84 100644 --- a/research/pandapower/04_porting_considerations.md +++ b/research/pandapower/04_porting_considerations.md @@ -109,11 +109,10 @@ These apply regardless of strategy. no upstream traceable source ("Rich's old notes"). Whatever path is chosen, this number must be carried forward visibly, with a comment, or explicitly retired with a justified replacement. -- **The "phase" property.** nopywer accepts `phase ∈ {1, 2, 3, "U", "Y", - None}`. Phases 1/2/3 map to pandapower's a/b/c. "U" / "Y" are sub-grid - labels with no electrical meaning in `analyze.py` (they appear only in - the reporting in `print_grid_info`). They have no pandapower equivalent - and would need to be threaded around any pandapower path. +- **The "phase" property.** nopywer accepts numeric phase assignments for + single-phase loads. Phases 1/2/3 map to pandapower's a/b/c. Missing or + non-numeric phase values have no pandapower equivalent and should be + treated as unassigned on any pandapower path. - **Performance and packaging.** pandapower with numba is not pip-friendly on every platform (Apple Silicon, NixOS, Alpine). Without numba it is slower but portable. For a CLI that may run on a laptop in a field, 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/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/10_three_phase_asymmetric_modelling.md b/research/pandapower/10_three_phase_asymmetric_modelling.md new file mode 100644 index 0000000..de01809 --- /dev/null +++ b/research/pandapower/10_three_phase_asymmetric_modelling.md @@ -0,0 +1,354 @@ +# 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") +- non-numeric values → legacy annotations with no pandapower equivalent; + treat them as unassigned for three-phase modelling + +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`. + +### 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 + +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 1c6fa29..2f0a017 100644 --- a/research/pandapower/README.md +++ b/research/pandapower/README.md @@ -32,6 +32,23 @@ 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). +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. +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 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 diff --git a/scripts/sensitivity_sweep.py b/scripts/sensitivity_sweep.py new file mode 100644 index 0000000..e4b893e --- /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"{'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): + 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() diff --git a/scripts/validate_optimiser_with_runpp.py b/scripts/validate_optimiser_with_runpp.py index fc74316..c312fba 100644 --- a/scripts/validate_optimiser_with_runpp.py +++ b/scripts/validate_optimiser_with_runpp.py @@ -29,18 +29,17 @@ def main() -> None: start = time.time() grid = optimize_layout(PowerGrid(nodes=nodes, cables={})) optim_duration = time.time() - start - print(f"Optimised ({optim_duration :.2f} s): ", - f"{len(grid.cables)} cables, " - f"{sum(c.length_m for c in grid.cables.values()):.0f} m total") + print( + f"Optimised ({optim_duration:.2f} s): {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}") @@ -53,9 +52,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}") @@ -71,17 +68,19 @@ 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()] 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 f73b18e..67e1878 100644 --- a/src/nopywer/constants.py +++ b/src/nopywer/constants.py @@ -5,12 +5,63 @@ # (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/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 064f3b6..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 @@ -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 ab5b0f0..7620896 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 import time from dataclasses import dataclass @@ -101,17 +99,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 @@ -139,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]] = {} diff --git a/src/nopywer/pp_interop/config.py b/src/nopywer/pp_interop/config.py index 78dde81..3f6f879 100644 --- a/src/nopywer/pp_interop/config.py +++ b/src/nopywer/pp_interop/config.py @@ -35,8 +35,8 @@ Loads - Not modelled at all. IEC 60909 short-circuit current is the source contribution; loads vanish during a bolted fault. -- `PowerNode.phase` (1/2/3/"U"/"Y") is ignored. Three-phase symmetric - fault by definition balances all phases. +- `PowerNode.phase` is ignored. Three-phase symmetric fault by definition + balances all phases. Topology - Only the generator-rooted radial tree implied by `from_node`/`to_node` @@ -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/tests/test_pp_interop.py b/tests/test_pp_interop.py index 69f3308..6180b98 100644 --- a/tests/test_pp_interop.py +++ b/tests/test_pp_interop.py @@ -1,7 +1,50 @@ +"""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") +from nopywer.constants import RHO_COPPER from nopywer.models import Cable16A, Cable32A, PowerGrid, PowerNode from nopywer.pp_interop import ( PandapowerGrid, @@ -13,6 +56,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 +75,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) @@ -38,11 +107,60 @@ 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) +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 +172,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 +198,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 +228,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 +255,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 new file mode 100644 index 0000000..5bef7ed --- /dev/null +++ b/tests/test_pp_interop_comparison.py @@ -0,0 +1,264 @@ +"""Side-by-side comparison: nopywer's tree walk vs pandapower's AC flow +on every existing analyze fixture. + +== Why this file exists == + +`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**? + +The answer turns out to depend on the fixture. Three different +shapes show up, each mapped to a real festival situation: + + 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 + +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. + +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 +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(): + """**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) + + 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(): + """**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) + + 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(): + """**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 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) + 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) diff --git a/tests/test_pp_interop_parity.py b/tests/test_pp_interop_parity.py new file mode 100644 index 0000000..0256dda --- /dev/null +++ b/tests/test_pp_interop_parity.py @@ -0,0 +1,210 @@ +"""Parity tests: nopywer's tree walk and pandapower's AC power flow agree +exactly when their modelling assumptions are matched. + +== Why this file exists == + +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 + +import pytest + +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. + + 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 + # canonical 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 canonical 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) diff --git a/tests/test_pp_interop_topology.py b/tests/test_pp_interop_topology.py new file mode 100644 index 0000000..a9bb9ae --- /dev/null +++ b/tests/test_pp_interop_topology.py @@ -0,0 +1,350 @@ +"""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 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; got {leaf_drops_tw}" + ) + assert max(leaf_drops_ac) - min(leaf_drops_ac) < 0.05, ( + 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 + # 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 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" }