diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000..db78e4e --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,183 @@ +# Asymmetric three-phase modelling + per-load phase planning + +## What this PR adds + +A pandapower-backed **asymmetric three-phase power flow** path for +festival grids, with the **per-load phase planning** needed to feed +it. Five user-facing pieces: + +1. **`io.load_geojson`** now accepts the round-tripped export schema + (`area_mm2`, `length_m`, `plugs_and_sockets_a`) alongside the + hand-authored input schema, and accepts list-valued `phase` for + multi-phase loads (e.g. `[1, 2]` for a 2-leg load). +2. **`pp_interop/phases/`** — two phase-distribution strategies + (greedy + round-robin) and a `phase_geojson` one-call wrapper for + the `load → plan → apply → write` workflow. +3. **`pp_interop/_powerflow_3ph.py`** — `to_pandapower_3ph` and + `compute_power_flow_3ph` for `runpp_3ph`. Returns a distinct + `Pandapower3phGrid` type so the dual `pp.load` / `pp.asymmetric_load` + layout is encoded in the type, not in a comment. Independent of + the balanced converter — no shared state, no mutation. +4. **`PowerNode.to_geojson` emits `phase`** when non-`None`, so phase + plans round-trip through the loader. +5. **Diagnostic logging** in `io` and `pp_interop` — legacy `"U"`/`"Y"` + string phase markers and unknown-property-key typos now surface + instead of being silently coerced. + +## Why + +`research/pandapower/10_three_phase_asymmetric_modelling.md` is the +motivation: nopywer's tree walk and the balanced `runpp` agree on a +balanced grid, but disagree exactly when balance matters (single- +phase loads piling onto one leg). The balanced solve averages the +imbalance away; the tree walk's `max(I_per_phase)` over-states +drop on lightly-loaded legs and under-states it on heavily-loaded +ones. Neither models the neutral conductor at all. + +`runpp_3ph` is the right fix. It surfaces two quantities a balanced +solve structurally cannot: + +- **per-leg voltage drop**, so loads on the heavy leg of an + imbalanced grid are sized against the leg they actually sit on; +- **neutral-conductor current**, which decides whether a + reduced-section-neutral cable (`3G6+½N`) is safe or the run needs + full-section neutral (`4G6`). + +Doc 12 (`12_runpp_3ph_findings.md`) is the first set of numbers from +this on the real 2026 east-grid fixture; the headline is that +balanced `runpp` is optimistic (~2.2× lower worst leg drop than +asymmetric at `--load-factor 0.5`), and that `cable_15` carries +20.4 A on its neutral under the greedy phase plan, which would have +been invisible in any balanced analysis. + +## How a reviewer can try it + +**Phase a fixture** (load → plan → apply → write, one call): + +```bash +uv sync --extra pandapower +uv run python -c ' +from nopywer.pp_interop import config +from nopywer.pp_interop.phases import phase_geojson +phase_geojson( + "tests/fixtures/2026-05-14_martin.geojson", + "/tmp/martin_phased.geojson", + usage_factor=config.DEFAULT_USAGE_FACTOR, + strategy="greedy", +) +' +``` + +**Run the asymmetric solve** with a printable report: + +```bash +# already-phased fixture at festival diversity (0.5x nameplate) +uv run python scripts/run_asymmetric.py \ + tests/fixtures/2026-05-14_martin_modified.geojson \ + --load-factor 0.5 + +# raw fixture with greedy phase planning +uv run python scripts/run_asymmetric.py \ + tests/fixtures/2026-05-14_martin.geojson \ + --plan greedy --load-factor 0.5 +``` + +Reads the fixture, snaps cables, optionally plans phases, scales +loads by `--load-factor`, runs both the balanced and asymmetric +solves side-by-side, and prints the top worst-leg voltage drops and +worst-neutral cables. + +The script has **one user-facing knob** — `--load-factor` — that +applies to both phase planning and the solver inputs. See its module +docstring for the full rationale; the short version is "the fraction +of nameplate this analysis is modelling" (`1.0` = worst case; +`0.5` = festival diversity; `0.2` = quiet period). The script's +pre-flight summary always prints the nameplate total, the load +factor, and the effective demand the solvers actually saw, so +reviewers can never be confused about which numbers fed which +result. + +## What I want a closer look at + +Four modelling assumptions live inline and are operator-tunable. All +documented in `pp_interop/config.py` and `research/pandapower/`, but +worth flagging here: + +- **`R0_OVER_R1 = X0_OVER_X1 = 4.0`** — rule-of-thumb default for + 4-/5-core LV flex with full-section neutral, TN-S earthing + (genset star-point bond only). Every neutral-current number in + doc 12 moves linearly with this. Multi-point N-PE bonding would + drop it to ~2.5. See `config.py` and `research/pandapower/10.1` § + "Picking `R0/R1` and `X0/X1` — what the ratios really mean". +- **`SOURCE_X0X_MAX = 1.0`, `SOURCE_R0X0_MAX = 0.1`** — assumes + TN-S genset with solidly-grounded star point (Yn). An + isolated-neutral (IT) genset would have no zero-sequence return + at all and these need overriding. +- **`DEFAULT_USAGE_FACTOR = 0.5`** — single conservative festival + diversity factor. Required at every `phases` call site (no + function default — the choice is event-specific and we don't + want it disappearing into a default). Also the default for the + `run_asymmetric.py` `--load-factor` flag, so library and CLI + agree on the project-wide reference value. +- **`phase = [1, 2]` splits power evenly across the listed legs** — + the multi-phase-loads-self-balance-evenly assumption. Documented + in `research/pandapower/10.md` under "Modelling assumption — + multi-phase loads self-balance evenly". Currently only matters + for `curious creatures` in the 2026 fixture; flagged for any + future genuinely-asymmetric multi-phase load. + +Also worth a second pair of eyes: + +- The **modified fixture** `tests/fixtures/2026-05-14_martin_modified.geojson` + was hand-edited to upsize four cables so the grid converges. The + resize sweep is in `research/pandapower/11`; the original raw export + is preserved as `2026-05-14_martin.geojson`. +- The **`phase = None` semantics** are explicit and important — `None` + is the modelling choice "balanced three-phase draw", not "missing + data". `pp.load` under `runpp_3ph` *is* the balanced-draw primitive, + so unphased loads need no special handling and don't contribute to + neutral current. Full per-layer behaviour table in + `src/nopywer/pp_interop/README.md` § "What `phase = None` means at + each layer". + +## Required reading for the technical reviewer + +- `src/nopywer/pp_interop/README.md` — API surface, runnable examples, + the balanced/asymmetric type distinction (`PandapowerGrid` vs + `Pandapower3phGrid` — independent nets, no shared state), the + "What `phase = None` means at each layer" per-layer table, and the + logging surface. +- `research/pandapower/10_three_phase_asymmetric_modelling.md` — what + the work was and the shopping list it came from. +- `research/pandapower/10.1_three_phase_theory.md` — physics of why + single-phase loads unbalance the *other* two legs and how the + zero-sequence parameters get picked. +- `research/pandapower/11_field_export_fixture_walkthrough.md` — the + real-fixture work that produced the modified fixture. +- `research/pandapower/12_runpp_3ph_findings.md` — first numerical + findings from `runpp_3ph` on the modified fixture. + +## Test count + +101 tests; all pass. Notable additions: + +- `tests/test_pp_interop_phases.py` — strategies, candidate selection, + apply, the round-trip through GeoJSON, the `phase_geojson` wrapper. +- `tests/test_pp_interop_powerflow_3ph.py` — `_phase_split` + parametrised across every `phase` form; per-physics tests for + balanced and single-phase loads under `runpp_3ph`. +- `tests/test_io_logging.py` — `caplog`-pinned warnings for the + string-marker and unknown-key paths. + +## Not in this PR (deliberately) + +- A `compare_3ph_with_tree_walk`. The tree walk collapses each node + to a single scalar voltage; there is nothing to line up against + `runpp_3ph`'s three per-leg voltages. The findings doc (12) is the + honest comparison. +- A CLI subcommand. `scripts/run_asymmetric.py` is a thin standalone + script; adding it to `python -m nopywer` would convert the existing + no-subcommand CLI into a subcommand layout (a breaking UX change). + Out of scope for this PR. +- A sensitivity sweep over the zero-sequence ratios. Listed as + remaining work in doc 10; would belong in `scripts/sensitivity_sweep.py`. diff --git a/research/pandapower/10.1_three_phase_theory.md b/research/pandapower/10.1_three_phase_theory.md new file mode 100644 index 0000000..960f533 --- /dev/null +++ b/research/pandapower/10.1_three_phase_theory.md @@ -0,0 +1,344 @@ +# Three-phase theory — what `runpp_3ph` actually does differently + +[`10_three_phase_asymmetric_modelling.md`](./10_three_phase_asymmetric_modelling.md) +is the shopping list: what to build, what data it needs, what it +would tell us. This companion doc is the **physics underneath it** — +why a balanced solve can take a shortcut, what that shortcut throws +away, and what the asymmetric solve has to do instead. + +It is written for a reader with solid general physics but no +practical power-systems background. The one genuinely +counter-intuitive result — a single-phase load unbalancing the two +legs it *doesn't* touch — is worked through in full, including a +verification experiment, because the first reaction to it is +(reasonably) "that must be a solver bug". + +## The balanced shortcut: why `runpp` collapses 3 → 1 + +A three-phase supply is three AC voltages, equal magnitude, 120° +apart in phase. If every load draws equally from all three, the +system is **symmetric**: whatever is happening on L1 is happening on +L2 and L3, just rotated 120° in time. So you don't solve three +circuits — you solve **one** and rotate the answer. + +That is exactly what `pp.runpp` (and the converter feeding it) does. +One voltage per node, one current per cable. The neutral conductor +carries nothing (see below), so it is ignored entirely. + +This is also why the balanced converter collapses *every* load — +even a genuinely single-phase festival stall — into an even +three-way split. It has no representation for "all the power on +L1"; the balanced model structurally cannot hold that fact. + +## What breaks with single-phase loads + +A festival is mostly single-phase loads — a 3 kW stall wired to one +leg. Now L1, L2, L3 are doing different things, the symmetry is +gone, and the one-circuit shortcut is invalid. You have to solve +three coupled circuits. That is `pp.runpp_3ph`. + +The standard tool for it is **symmetrical components** (the +Fortescue transformation) — a change of basis, much like an +eigendecomposition of the three-phase system. Any set of three +phasors, however lopsided, is rewritten as the sum of three tidy +sets: + +``` + positive sequence 3 equal phasors, 120° apart, normal rotation + negative sequence 3 equal phasors, 120° apart, REVERSE rotation + zero sequence 3 IDENTICAL phasors, all in phase, no rotation +``` + +Why bother: in this basis a symmetric *network* **decouples** — the +three sequences flow through it independently, each as its own +simple circuit, and you add the results. A balanced load excites +**only** the positive sequence — which is precisely why the +balanced solver only ever needed one circuit. An unbalanced load +excites all three. + +## The zero sequence *is* the neutral + +This is the physically load-bearing part. + +The zero-sequence component is "the same current in all three +phases at once". Ask where that current goes. The three phase +currents of a balanced system sum to zero — equal, 120° apart, they +cancel. Zero-sequence currents do **not** cancel; they add. They +physically *cannot flow* unless there is a fourth path: the neutral +conductor, or earth. + +So: + +- **Neutral current = sum of the three phase currents = 3 × the + zero-sequence current.** Balanced → phase currents cancel → + neutral carries nothing (the shortcut from the first section). + Unbalanced → they do not cancel → the neutral carries the + imbalance. +- This is why `runpp_3ph` needs **zero-sequence cable parameters** + (`r0`, `x0`) that `runpp` never asked for. They describe the + impedance of the neutral-and-earth return loop — a path the + balanced model never had to acknowledge. +- And why the **source earthing** matters (`r0x0_max`, `x0x_max` on + the ext_grid): whether the generator's star point is grounded + decides whether zero-sequence current can return through the + source at all. + +The neutral current is genuinely new information. No balanced tool, +and no nopywer tree walk, produces it — and it is the number that +decides whether a reduced-section-neutral cable is adequate or the +run needs full-section neutral. + +## Picking `R0/R1` and `X0/X1` — what the ratios really mean + +The zero-sequence cable impedance `Z0` is not a separate +measurable property of a piece of copper. It is the impedance the +*loop* presents — phase out, return back through whatever path the +imbalance current can find. The relationship is: + + Z0 ≈ Z_phase + 3 · Z_return + +where `Z_return` is the impedance of the return conductors carrying +the neutral current. The factor of 3 falls out of the symmetrical- +components algebra (three phase currents all in phase summing into a +single return loop) — it is not a fudge. + +So `R0/R1` (and `X0/X1`) is shorthand for **how heavy the return +loop is, relative to a single phase conductor**. It depends on two +things and only two things: + +1. **The cable build** — how many return conductors are there, what + cross-section, are they in parallel? +2. **The earthing topology of the wider system** — which of those + conductors are actually bonded to the source neutral such that + they can complete the loop? + +### What the festival default (`R0/R1 = 4.0`) assumes + +Festival kit is conventionally 5-core HO7RN-F flex with: + +- 3 phase conductors, +- 1 **full-section Neutral** (same cross-section as a phase), and +- 1 **full-section Protective Earth**. + +"Full-section neutral" matters: the return path through N alone has +`R_N ≈ R_phase`, so without any PE contribution +`Z_return ≈ Z_phase` and: + + Z0 ≈ Z_phase + 3 · Z_phase = 4 · Z_phase → R0/R1 ≈ 4 + +The reason the PE conductor does not lower this further is that +festivals typically run **TN-S earthing** — the Neutral and Protective +Earth are bonded *only at the generator star point*. Downstream the +PE is held at near-zero potential for safety; it is not a parallel +current-carrying path for steady-state imbalance current. The PE is +sized full-section for **fault current capacity** (a phase-to-earth +fault has to clear the protective device in time), not for sharing +the steady-state neutral return. + +### When to override + +| Cable + earthing change | Effect on Z_return | Resulting R0/R1 | +|---|---|---| +| Reduced-section N (half-section), TN-S | ≈ 2·Z_phase | ≈ 7 | +| Full-section N only, TN-S *(default)* | ≈ Z_phase | ≈ 4 | +| Full-section N **and** PE, **bonded at every distro** | ≈ Z_phase / 2 | ≈ 2.5 | +| Combined PEN (TN-C throughout) | ≈ Z_phase / 2 | ≈ 2.5 | +| IT (isolated neutral, no source bond) | ∞ | undefined — no zero-seq return | + +The third row is the case you have to think about on site. If +the site electricians bond N and PE together at the genset *and at +every distro*, the two full-section conductors are in parallel as +return, cutting `Z_return` roughly in half. That is a real +configuration (multiple-bonded or "mesh-earthed" TN-C-S) but it is +a choice the festival makes, not a property of the cable. Default +TN-S — one bond at the genset — does **not** put the PE in parallel +and `R0/R1 ≈ 4` is the right number. + +### Why `X0/X1` doesn't move when `R0/R1` does + +Resistance scales with the cross-section of copper carrying the +current. Reactance does not — it is set by **geometry**: the area +enclosed by the current loop, conductor spacing, and twist. Adding a +parallel PE conductor barely changes the loop area (the conductors +are bundled inside the same sheath), so `X0` is essentially +unaffected by parallel-return tricks. Keep `X0/X1` at the cable- +catalogue rule-of-thumb (3-5×, default 4.0) unless you have a +measured value for the specific cable. + +### Sources + +The 3-5× rule-of-thumb range for both `R0/R1` and `X0/X1` on +4/5-core LV flex is the standard cable-data input to short-circuit +calculations under **IEC 60909-2** ("Data of electrical equipment +for short-circuit current calculations"). Roeper, *Short-Circuit +Currents in Three-Phase Systems* (the classic reference behind +IEC 60909), gives the same range for unarmoured multi-core LV +cables with full-section neutral. Manufacturer data sheets for +HO7RN-F and similar festival flex do **not** publish a zero- +sequence impedance — the ratios above are the engineering +substitute, and the doc 12 numerical findings move linearly with +them. Sensitivity to this assumption is a known gap, flagged in +doc 10's remaining-work list. + +## The puzzle: one single-phase load unbalances the *other two* legs + +Put a 6 kW load on L1 only. Obviously L1 sags. But the asymmetric +solve also returns **L2 and L3 at different voltages from each +other** — even though neither carries any load current. + +The honest first reaction is: the cable has three-fold symmetry, +nothing physically distinguishes L2 from L3, so this must be a +solver artefact. It is worth taking that objection seriously, +because half of it is correct. + +### What is symmetric, and what is not + +The **passive cable network** genuinely does have three-fold +symmetry. The cable does not distinguish L2 from L3. If the cable +were the whole story, L2 and L3 would have to be equal. + +The cable is not the whole story. The **source** breaks the +symmetry — and it does so in *time*, not space. + +### The rotation is temporal, not spatial + +A three-phase source has a **phase sequence**: the three voltages +peak in a fixed order — L1, then L2, then L3, then L1. That ordering +is a real physical fact, set by which way the generator shaft +spins. It is measurable and consequential — it is why a three-phase +motor turns a particular direction, and swapping any two phases +reverses it. + +So there is **no preferred spatial axis** in the cable; the +conductors are geometrically symmetric. The handedness is in the +*time-ordering* of the voltage peaks. + +Once L1 is singled out as the loaded phase, L2 and L3 are no longer +interchangeable: + +- L2 is "the phase that peaks 120° **after** L1" — it *lags*. +- L3 is "the phase that peaks 120° **before** L1" — it *leads*. + +Lagging and leading are physically different relationships. The +L2↔L3 swap on its own is *not* a symmetry of the system. The +symmetry is {swap L2↔L3 **and** reverse the rotation}. The system +has a handedness, fixed by the source — a parity-like situation. + +### The mechanism, in symmetrical components + +1. A single-phase load draws current on L1 only (`I_b = I_c = 0`). + Decomposed into sequences, that forces + **`I₀ = I₁ = I₂ = I_a / 3`** — equal in all three. This follows + purely from `I_b = I_c = 0`; it does not depend on the load being + constant-power. + +2. The source EMF is **purely positive-sequence** — this is the + "rotation direction" written mathematically. Only `E₁ ≠ 0`. + +3. Recombining the sequence voltages into phase voltages, the two + unloaded legs get the positive- and negative-sequence terms + **swapped**: + + ``` + V_b = V₀ + a²·V₁ + a ·V₂ + V_c = V₀ + a ·V₁ + a²·V₂ (a = e^{j120°}) + ``` + +4. For a passive network `Z₂ = Z₁`. If you *also* had `Z₀ = Z₁`, + every sequence drop would be identical and the swap in step 3 + would cancel — `V_b = V_c`. + +5. But `Z₀ ≠ Z₁` in reality. Zero-sequence current returns through + the **neutral-and-earth loop** — a physically different path + than a phase conductor (larger loop area, includes earth), which + is why nopywer models `r0/x0 ≈ 4 × r1/x1`. That leaves a residual + zero-sequence voltage-drop term that is *common* to `V_b` and + `V_c` — but it is subtracted from the two source phasors `a²E` + and `aE`, which sit at **different angles**. Subtract the same + vector from two equal-length vectors pointing different ways and + you get **different lengths**. That is the L2/L3 split. + +So the asymmetry needs **two** ingredients, and removing either one +restores equality: + +- the source's fixed rotation direction (only `a²E` and `aE` + appear, at different angles); +- the zero-sequence return path being electrically different from a + phase (`Z₀ ≠ Z₁`), producing a common drop term those two + differently-angled phasors do not absorb equally. + +### Verification — not a solver quirk + +The two-ingredient claim is directly testable: force the +zero-sequence impedance to equal the positive-sequence impedance +everywhere and the split must vanish. A single 6 kW load on L1, fed +by 50 m of `Cable32A`: + +| Case | `r0/r1`, `x0/x1` | L1 (pu) | L2 (pu) | L3 (pu) | L2 − L3 | +|---|---|---:|---:|---:|---:| +| A — realistic zero-seq | 4, 4 | 0.90923 | 1.01023 | 1.04135 | **−0.03111** | +| B — zero-seq forced = positive-seq | 1, 1 | 0.95192 | 1.00485 | 1.00485 | **+0.00000** | + +In Case B, with `Z₀ = Z₁` everywhere, **L2 and L3 are equal to the +last decimal**. Restore the realistic zero-sequence values and the +split reappears. The effect has a precise physical on/off switch — +exactly what real physics looks like, and exactly what a numerical +artefact does not. (The constant-power load nonlinearity is not +involved: Case B goes to a flat `+0.00000`, so the load model does +not break the L2/L3 symmetry — only `Z₀ ≠ Z₁` does.) + +The takeaway: a single-phase load makes the **whole bus** +asymmetric, including the legs carrying no load, because the +imbalance current it forces back through the neutral/earth loop +interacts with a source that has a built-in direction of rotation. +Capturing that coupling is the entire reason `runpp_3ph` exists +instead of solving one leg and rotating the answer. + +## What the tool computes differently + +| | balanced `runpp` | asymmetric `runpp_3ph` | +|---|---|---| +| Per node | one voltage | three voltages (per leg) + an unbalance % | +| Per cable | one current | three currents + the neutral current | +| Loads | every load split evenly 3 ways | single-phase loads on their real leg | +| Cable model | R, X (positive sequence) | also r0, x0 (zero sequence / neutral) | +| Source | short-circuit strength | also its zero-sequence earthing | +| Equations solved | one nonlinear set | three coupled nonlinear sets | + +## Why the asymmetric solve is harder to converge + +The balanced solver finds the fixed point of one set of nonlinear +equations. `runpp_3ph` solves three coupled sets. More importantly: +each constant-power load on each leg has its own "nose" on the P–V +curve — the point past which no stable voltage exists (the collapse +mechanism in [`11_field_export_fixture_walkthrough.md`](./11_field_export_fixture_walkthrough.md)). + +A balanced solve averages load across the three legs, so it sees +the *average* leg loading. An asymmetric solve sees each leg's +*actual* loading — and a single heavily-loaded leg can be past its +nose even when the three-leg average is comfortably fine. + +This is observable on the 2026 modified fixture: it converges +balanced at full nameplate but **not** asymmetric, because +concentrating real loads onto real legs pushes the heaviest leg +over the edge that the averaging had hidden. The headline: the +balanced model is not merely *less detailed* than the asymmetric +one — it can be **optimistic in a way that hides a grid that +physically cannot run**. + +## Where this connects to the code + +- The change of basis and the per-leg / neutral results are what + `pp_interop/_powerflow_3ph.py` produces (`compute_power_flow_3ph`, + `PowerFlow3phResults`). +- The zero-sequence ratios (`R0_OVER_R1`, `X0_OVER_X1`) and source + earthing (`SOURCE_X0X_MAX`, `SOURCE_R0X0_MAX`) are the + `config.py` defaults — engineering rule-of-thumb, since they have + no nopywer analogue and are not on flex spec sheets. The derivation + of the `R0/R1 = 4.0` default (full-section N, TN-S earthing) and + the override table for other earthing topologies live in the + "Picking `R0/R1` and `X0/X1` — what the ratios really mean" + section above; the `config.py` comment summarises that table at + the call site. +- The per-load phase that decides which leg a load lands on is + synthesised by `pp_interop/phases` (doc 10, strategies B and D). diff --git a/research/pandapower/10_three_phase_asymmetric_modelling.md b/research/pandapower/10_three_phase_asymmetric_modelling.md index de01809..f12c3bd 100644 --- a/research/pandapower/10_three_phase_asymmetric_modelling.md +++ b/research/pandapower/10_three_phase_asymmetric_modelling.md @@ -46,6 +46,7 @@ nopywer's `PowerNode.phase` already carries this: - `1`, `2`, `3` → single-phase load on L1/L2/L3 - `None` → balanced (or "to be assigned later") +- `[1, 2]` (a list of legs) → multi-phase load split across the listed legs - non-numeric values → legacy annotations with no pandapower equivalent; treat them as unassigned for three-phase modelling @@ -53,6 +54,30 @@ 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. +#### Modelling assumption — multi-phase loads self-balance evenly + +When `phase` is a list (e.g. `[1, 2]` for a 10 kW load), both the +GeoJSON loader (`io.load_geojson`) and the pandapower converter +(`_powerflow_3ph._phase_split`) divide the total power **evenly** +across the listed legs — 5 kW on L1, 5 kW on L2, nothing on L3 in +the example. This is a deliberate assumption, not a measurement: + +- It assumes the load itself balances its own internal draw across + the legs it has been wired to. For most genuinely multi-phase + festival kit (3-phase motors, balanced 2-leg space heaters, + battery chargers with multi-input rectifiers) this is reasonable. +- It will be **wrong** for loads that present an inherently + asymmetric draw across the legs they connect to — some + line-to-line equipment, and any setup where one phase of a + multi-phase plug actually feeds a different sub-load than the + other. Flag these explicitly; the data model has no way to + represent an uneven multi-phase split today. +- Today this only matters for `curious creatures` in the 2026 field + export (wired `[1, 2]`); doc 11 records the explicit choice. If + more multi-phase loads appear with non-symmetric internals, the + fix is to add a per-leg-fraction field to `PowerNode.phase` + rather than to hack the split site-side. + ### 2. Per-cable zero-sequence parameters Pandapower's `r_ohm_per_km`, `x_ohm_per_km`, `c_nf_per_km` are @@ -181,9 +206,18 @@ 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: +The 2025 fixture has 54 nodes with no phase assignment; the 2026 +field export (doc 11) has 26, also unphased. Four strategies for +adding phase data, ordered from realistic to worst-case. + +All four share one modelling input — a **usage factor**. Festival +loads almost never draw nameplate power simultaneously, so phase +balancing on nameplate over-fits to a peak that never happens. The +strategies balance on `power × usage_factor` instead; the working +assumption (and the default in code) is **0.5×**. The factor also +sets which loads can be single-phased at all: anything whose +effective power exceeds a single 16 A leg (~3.3 kW) stays +multi-phase regardless of strategy. ### Strategy A — historical reconstruction @@ -216,6 +250,83 @@ 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. +### Strategy D — greedy least-loaded leg + +Size-aware balancing. Sort the single-phase loads heaviest-first +and drop each onto whichever leg currently carries the least power +(at the usage factor), seeded with whatever balanced and +pre-assigned loads are already on the grid: + +```python +legs = fixed_leg_seed(grid, usage_factor) # balanced + pre-phased loads +for name, watts in sorted(candidates, key=lambda c: -c[1]): + target = min(range(3), key=lambda i: legs[i]) + legs[target] += watts + node.phase = target + 1 +``` + +Heaviest-first matters: it places the big loads while the legs are +near-empty and leaves the small loads to fine-tune. This is the +standard greedy multiway-partition heuristic — not provably +optimal, but on festival-shaped inputs it lands far closer to a +level split than round-robin, and unlike B it is insensitive to +the order loads appear in the fixture. The cost is that it needs +trustworthy load sizes; if nameplate figures are guesses, B's +positional honesty may be preferable. + +**Recommended over Strategy B when load sizes are reliable.** + +## What the strategies actually produce + +Strategies B and D are now implemented in `pp_interop/phases` +(round-robin and greedy in separate modules; shared candidate +selection, fixed-leg seed, and balance metric in `_common`). Both +return a `PhaseAssignment` and do not mutate the grid — +`apply_assignment` commits one. + +Running both on the 2026 modified fixture (doc 11) at 0.5× usage — +19 single-phase candidates; `garden of joy`, `curious creatures`, +`desert dessert` left multi-phase as too big for one leg: + +| Strategy | L1 / L2 / L3 (kW) | Leg balance | Worst tree-walk vdrop | +|---|---|---:|---:| +| B round-robin | 16.17 / 15.13 / 12.83 | 9.5 % | 13.74 % (`jamhouse`) | +| D greedy | 14.67 / 14.83 / 14.63 | **0.6 %** | 13.70 % (`jamhouse`) | + +Two findings: + +- **Greedy crushes the imbalance** — 9.5 % → 0.6 %, a near-level + three-way split — exactly what a size-aware heuristic should do + against round-robin's positional lottery. +- **But the worst node's voltage drop barely moves** — 13.74 % → + 13.70 %, the same to a tenth of a point. The reasons are + structural, not coincidental: + - The worst node's *own* feed is unchanged. `jamhouse` is a 3 kW + load on L1 under both strategies, so its leaf cable carries the + same current and contributes the same ~7.2 V drop either way. + - On the *shared trunk* cables, the tree walk charges drop on + `max(current_per_phase)` — only the single heaviest leg on each + cable matters. Greedy balances the **generator** totals, but + that does not balance each intermediate cable's own subtree, and + the max-phase rule ignores everything but the top leg. Greedy's + gains on one cable are offset by small losses on another; the + path total nets out flat. + - The big multi-phase loads (`garden of joy`, `curious creatures`) + sit on the trunk regardless and dominate its current. Shuffling + 19 small single-phase candidates around the legs is noise next + to them. + + So **phase balancing and voltage-drop relief are decoupled + levers.** Greedy is the right tool for neutral-current and + generator-loading problems; it is *not* a substitute for cable + sizing (doc 11). A strategy can score beautifully on leg balance + and leave the worst node exactly where it was. + +This also sharpens a caveat for the eventual `runpp_3ph` +validation: a strategy that scores beautifully on leg balance can +still leave a fixture with the same out-of-spec worst node. Both +metrics have to be reported. + ## Implementation shopping list Rough estimate of the work, in three layers: @@ -223,11 +334,13 @@ 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. +- Phase synthesis for unphased fixtures — **done.** Strategies B and + D ship in `pp_interop/phases`; the 2026 modified fixture has had + the greedy assignment written into it. `input_nodes.geojson` could + get the same treatment with a one-liner. - New synthetic fixtures specifically for asymmetric stress (e.g. all - loads on L1) could live alongside the existing tests. ~30 lines per - fixture. + loads on L1, i.e. Strategy C) could live alongside the existing + tests. ~30 lines per fixture. ### Layer 2 — Config defaults @@ -277,15 +390,25 @@ 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 | +| Layer | Status | +|---|---| +| Add phase to `analyze_input.geojson` | done | +| Phase synthesis strategies B + D (`pp_interop/phases`) | **done** | +| Config defaults — zero-sequence + source vector group (`config.py`) | **done** | +| Asymmetric conversion + `compute_power_flow_3ph` (`_powerflow_3ph.py`) | **done** | +| Tests — `_phase_split`, conversion, per-leg flow, neutral current | **done** | +| Findings doc ([`12_runpp_3ph_findings.md`](./12_runpp_3ph_findings.md)) | **done** | + +The conversion landed as `to_pandapower_3ph` returning a distinct +`Pandapower3phGrid` type. It builds an **independent** pandapower +net via the shared `_build_net_skeleton` (which `to_pandapower` also +uses), so the balanced and asymmetric paths are sibling consumers of +the same scaffold — no mutation of either's output, no shared state. +Loads are split into `net.load` (for `phase is None`) and +`net.asymmetric_load` (for explicit `int` / `list` phases), keyed in +`balanced_load_idx` and `asymmetric_load_idx` respectively so the +dual-table layout is visible in the type. See the pp_interop README +"Two converters, two grid types" section for the rationale. The biggest unknown is whether `runpp_3ph` converges cleanly on the 2025-scale fixture (51 nodes) given its documented sensitivity, @@ -300,9 +423,9 @@ Three things we currently don't know: 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. +— and the gap shape is the key open question. Today we report only +the balanced-flow numbers; the asymmetric numbers might be very +different. ### 2. Neutral currents per cable @@ -338,17 +461,22 @@ only way to know is to measure with `runpp_3ph`. ## Where it slots in -If pursued, this work would land as: +The code and the first findings doc are done — +`pp_interop/phases` (phase synthesis), `config.py` (zero-sequence +defaults), `pp_interop/_powerflow_3ph.py` (`to_pandapower_3ph` + +`compute_power_flow_3ph`) all with tests, and +[`12_runpp_3ph_findings.md`](./12_runpp_3ph_findings.md) with the +first run on the 2026 modified fixture. Headline result: balanced +`runpp` is optimistic — at 0.5× usage `runpp_3ph` reports a worst +leg drop ~2.2× higher than the balanced solve and on a different +node, and at full nameplate `runpp_3ph` has no AC operating point +at all while balanced still converges. + +What remains: -- 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. + sweep over the new zero-sequence ratio defaults (the doc 12 + numbers shift with them; the directions do not). The conversion layer (`to_pandapower`) and short-circuit module stay backward-compatible — `runpp_3ph` is purely additive. diff --git a/research/pandapower/11_field_export_fixture_walkthrough.md b/research/pandapower/11_field_export_fixture_walkthrough.md new file mode 100644 index 0000000..c7848c9 --- /dev/null +++ b/research/pandapower/11_field_export_fixture_walkthrough.md @@ -0,0 +1,277 @@ +# Plugging a field-export GeoJSON into the pandapower code + +[`10_three_phase_asymmetric_modelling.md`](./10_three_phase_asymmetric_modelling.md) +ended on an open question: would `runpp_3ph` even converge on a +2025-scale fixture, and is round-robin phase synthesis meaningful +enough to compare against? Before we can answer that we need a +real grid to point it at. Martin supplied one — +[`tests/fixtures/2026-05-14_martin.geojson`](../../tests/fixtures/2026-05-14_martin.geojson), +"this year's east grid with some loads made up, some real" — and +asked the obvious question: **how do I plug this into the existing +pandapower code to run it?** + +This doc answers that. It turns out there are two gaps in the way, +one trivial and one not. + +## What the file actually is + +It is a **plugin *export*, not a plugin *input*.** 52 features: +26 loads, 1 generator, 25 cables. Every feature already carries +*computed* fields — `current_a`, `cum_power_kw`, `vdrop_volts` on +cables; `voltage`, `vdrop_percent`, `cum_power`, `distro` on +points. Those are nopywer outputs written back into the GeoJSON, +not inputs. + +That matters because the export schema and the input schema +`io.load_geojson` expects are **not the same**: + +| Quantity | Export key (this file) | `io.load_geojson` reads | +|-----------------|------------------------|-------------------------| +| Cable CSA | `area_mm2` | `area` | +| Plug rating | `plugs_and_sockets_a` | `plugs&sockets` | +| Cable length | `length_m` | `length` | +| Load power | `power` | `power` ✓ | +| Node name | `name` | `name` ✓ | +| Phase | *(absent)* | `phase` | + +`io.load_geojson` does not error on the unknown keys — it just +falls back to defaults. Probed directly: + +``` +load_geojson('2026-05-14_martin.geojson') + -> 27 nodes, 25 cables + sample cable: area_mm2 = 2.5 (default — real value 6.0 ignored) + plugs = 16.0 (default — real value 32.0 ignored) +``` + +So the file **loads silently and wrongly**: every cable comes in +as 2.5 mm² / 16 A regardless of what the export says. The cable +topology, on the other hand, survives fine — `io.load_geojson` +ignores the export's explicit `from`/`to`/`nodes` and snaps by +coordinate proximity instead (`analyze._snap_cables_to_nodes`, +within `CONNECTION_THRESHOLD_M`). All 25 cables snap to two nodes; +`analyze` builds the tree without complaint and reports 88.3 kW at +the generator. + +### Gap 1 — schema translation (trivial) + +Either rename the keys on the way in, or teach `io.load_geojson` +to accept both spellings. A one-shot remap is five lines: + +```python +fc = json.load(open("2026-05-14_martin.geojson")) +for f in fc["features"]: + p = f["properties"] + for export_key, input_key in ( + ("area_mm2", "area"), + ("plugs_and_sockets_a", "plugs&sockets"), + ("length_m", "length"), + ): + if export_key in p: + p[input_key] = p[export_key] +nodes, cables = load_geojson(fc) # load_geojson accepts a dict +``` + +The cleaner fix is to make `io.load_geojson` read `props.get("area") +or props.get("area_mm2")` etc., so a round-tripped export loads +correctly without a pre-pass. That belongs in `io.py`, not in +every caller. Worth doing — exports round-tripping back as inputs +is going to keep happening. + +## Gap 2 — the grid does not have an AC solution + +With the schema fixed (real 6 mm² / 32 A cables), the balanced +path is exactly what doc 06/07 already built: + +```python +from nopywer import analyze, pp_interop +grid = PowerGrid(nodes=nodes, cables=cables) +analyze._snap_cables_to_nodes(grid) +analyze.analyze(grid) +ppg = pp_interop.to_pandapower(grid) +res = pp_interop.compute_power_flow(ppg) # <-- LoadflowNotConverged +diff = pp_interop.compare_with_tree_walk(grid) +``` + +`pp.runpp` **does not converge** on this fixture — not with the +defaulted cables, and not with the corrected ones either. This is +not a bug in the converter. It is the constant-power feedback +mechanism from +[`08_why_tree_walk_and_ac_disagree.md`](./08_why_tree_walk_and_ac_disagree.md) +taken to its limit: the fixture's own exported `vdrop_percent` +values run as high as **56 %** (nodes sitting at ~101 V on a 230 V +nominal). A constant-power load draws *more* current as its +voltage sags, which sags it further. Past the nose of the P–V +curve there is simply no fixed point for Newton-Raphson to find. + +Scaling every load down confirms it is load-magnitude driven, not +numerical: + +| Load scale | Generator total | `runpp` result | min bus voltage | +|-----------:|----------------:|-----------------------|----------------:| +| 1.0 | 88.3 kW | **did not converge** | — | +| 0.5 | 44.1 kW | converged | 0.66 pu | +| 0.3 | 26.5 kW | converged | 0.84 pu | +| 0.2 | 17.7 kW | converged | 0.90 pu | + +Even at half load the worst bus is at 0.66 pu — a 34 % drop, well +past anything a real festival grid would tolerate. The grid as +exported is not a borderline case; it is deep in the +non-physical region. + +This is itself a **finding**, and arguably the most useful one: + +- **nopywer's tree walk still produces numbers here** (88.3 kW, + vdrops up to 56 %) because it is an explicit forward pass — it + never has to converge anything. It will happily report a grid + that cannot physically exist. +- **pandapower refusing to converge is the correct answer.** "No + AC solution" is real information: this grid needs bigger cable, + shorter runs, or a closer generator before any phase-balancing + question is even worth asking. +- It also re-frames doc 10's open question. Running `runpp_3ph` on + *this* fixture as-is is pointless — if the balanced solver can't + find a fixed point, the asymmetric one certainly won't. The + 3-phase work needs a fixture that converges balanced first. + +## So: how to plug it in + +For Martin, concretely: + +1. **Translate the schema.** Five-line key remap above, or the + `io.py` fix. Without this the cables are silently wrong. +2. **Run the balanced path first** — `to_pandapower` → + `compute_power_flow`. On this fixture it will raise + `LoadflowNotConverged`. That is the headline result, not an + error to work around. +3. **To get past non-convergence**, the grid has to be made + physical first: scale the made-up loads down to realistic + values, or fix the under-sized cable runs. At ~0.2× it + converges with sane voltages and `compare_with_tree_walk` + becomes meaningful. +4. **Phase assignment is still absent.** Every node in this export + has `phase = None` — note that the phase work went + "without great results" is borne out; none of it made it into + the file. So the asymmetric `runpp_3ph` path from doc 10 has no + real data to chew on here either. Round-robin synthesis + (Strategy B) remains the only option, and only once the grid + converges balanced. + +## Worked example: making the grid converge + +`tests/fixtures/2026-05-14_martin_modified.geojson` is an editable +copy used to test the "fix the under-sized runs" path from step 3 +above. The exercise: resize cables one at a time and watch where +the bottleneck moves. + +**Tracing the worst bus.** At 0.5× load (where the original +converges), the worst bus is `jamhouse` at 0.66 pu. Walking its +path back to the generator, one cable dominates: `cable_23`, the +`generator → distro1` trunk — 2.5 mm² flex (16 A rated) carrying +45 A, ~55 V of drop on that segment alone. It feeds the entire +`distro1` subtree: 15 loads, 38.8 kW, with `curious creatures` +(10 kW) the single biggest lump. The trunk is on the *thinnest* +cable type in nopywer's catalogue while carrying a third of the +grid. + +**Resizing, one cable at a time.** nopywer's catalogue has exactly +one single-phase type (`Cable16A`, 2.5 mm²); everything bigger +(32/63/125 A) is three-phase. So any trunk fix is also a +1-phase → 3-phase change. Iterating on the modified fixture at +*full* load: + +| Change | Result | +|---|---| +| baseline (export as-is) | no AC solution | +| `cable_23` trunk → 125 A 3P | still no solution; converges at 0.6–0.7× | +| `cable_3` (GoJ feed) → 32 A 3P | **converges**, vmin 0.576 (`garden of joy`) | +| `cable_3` → 63 A 3P | vmin 0.707, worst bus → `desert dessert` | +| `cable_1` (other gen trunk) → 125 A 3P | vmin 0.779, worst bus → `jamhouse` | +| `cable_2` (CC feed) → 32 A 3P | vmin 0.783, two cables left ~1.0× rating | + +**Worst voltage drop across both axes.** Sweeping each cable +config against the reduced-power scales gives the full picture — +each cell is the max bus voltage drop (`1 − vmin`); "—" is no AC +solution at all: + +| Cable config | 1× | 0.7× | 0.6× | 0.5× | 0.3× | +|---|---|---|---|---|---| +| S0 original (most 2.5 mm²) | — | — | — | 34 % | 16 % | +| S1 +`cable_23`→125 A | — | 44 % | 30 % | 23 % | 12 % | +| S2 +`cable_3`→32 A | 42 % | 22 % | 18 % | 14 % | 8 % | +| S3 `cable_3`→63 A | 29 % | 18 % | 15 % | 12 % | 7 % | +| S4 +`cable_1`→125 A | 22 % | 14 % | 12 % | 10 % | 5 % | +| S5 +`cable_2`→32 A | 22 % | 14 % | 12 % | 10 % | 5 % | + +Two things stand out. First, **cable resizing and load reduction +buy roughly the same thing** — S1 at 0.5× and S0 at 0.3× both land +near a 12–16 % drop; you can trade one for the other. Second, +**even the fully-resized grid (S5) only reaches 22 % drop at full +load** — better than "no solution", still 4× over the 5 % NF C +15-100 threshold. Convergence is a low bar; spec compliance is a +much higher one, and these five cable swaps do not clear it. S5 +only meets 5 % at 0.3× load. (S5 matches S4 because `cable_2` was +never the binding constraint — that swap was a *type* correction, +not a capacity one; see below.) + +The pattern is the whole point: **every fix exposes the +next-thinnest segment.** The worst bus hops `garden of joy` → +`desert dessert` → `jamhouse` as each bottleneck is cleared. This +is not a one-cable problem — 21 of the 25 cables are 2.5 mm². Going +from "no solution" to "converges, two cables marginally over" took +four targeted resizes, and getting fully within the 5 % drop +threshold would need a systematic `pick_cable_for`-against-actual- +current pass over every cable, not worst-bus whack-a-mole. + +**Asymmetric loads need multi-core cable.** `curious creatures` +was re-modelled as a genuine two-phase load (5 kW on L1, 5 kW on +L2) via the new list-valued `phase` support — `phase: [1, 2]` +splits power evenly across the listed legs. That immediately +surfaced a modelling error the balanced view hid: its feed +`cable_2` was a *1-phase* cable, which physically cannot carry two +phases regardless of rating. It is not "underrated", it is the +wrong type. The fix is the smallest 3-phase cable +(`Cable32A`, 6 mm²) — each live leg pulls ~24 A, under the 32 A +rating, with the unused L3 core idle. The lesson for the doc 10 +`runpp_3ph` work: per-load phase assignment and cable *type* are +coupled — assigning phases can invalidate the cable feeding the +load. + +**Modelling assumption — the 50/50 split is an assumption.** The +even L1/L2 split for `curious creatures` is a choice, not a +measurement. Both the loader and the converter assume any +multi-phase load self-balances evenly across the legs it has been +wired to (see doc 10, "Modelling assumption — multi-phase loads +self-balance evenly"). This is reasonable for genuinely balanced +2-leg equipment; it would be wrong for a setup where the two legs +actually feed different sub-loads inside the same plug. There is +no way to express a non-symmetric multi-phase split in the data +model today — if the field reality of `curious creatures` (or any +future multi-phase load) turns out to be e.g. 6/4 rather than 5/5, +that needs a model change, not a fixture hack. + +## What this changes upstream + +Carrying back into the codebase rather than leaving as research +notes: + +- **`io.load_geojson` now accepts the export schema** (done — + `_EXPORT_KEY_ALIASES` maps `area_mm2`/`plugs_and_sockets_a`/ + `length_m` onto the input-schema keys) **and list-valued + `phase`** for multi-phase loads. Exports round-tripping as + inputs is a normal workflow; the old silent-default behaviour + was a trap. +- **The `LoadflowNotConverged` path needs a deliberate story.** + Right now `compute_power_flow` lets pandapower's exception + propagate raw. For a planning tool, "this grid has no AC + solution — your worst node is past voltage collapse" is a + *result* worth surfacing cleanly, not a stack trace. This is a + natural companion to the doc 10 work, not a blocker for it. + +## Where it slots in + +Same branch story as doc 10 (`feat/pandapower-runpp-3ph` off the +current PR stack). The `io.py` schema fix is independent and +small enough to land on its own. The non-convergence handling +belongs alongside `_powerflow.py`. None of it touches the +converter or the short-circuit module. diff --git a/research/pandapower/12_runpp_3ph_findings.md b/research/pandapower/12_runpp_3ph_findings.md new file mode 100644 index 0000000..9180442 --- /dev/null +++ b/research/pandapower/12_runpp_3ph_findings.md @@ -0,0 +1,211 @@ +# `runpp_3ph` findings on the 2026 east-grid fixture + +The shopping list in [`10`](./10_three_phase_asymmetric_modelling.md) +and the theory in +[`10.1`](./10.1_three_phase_theory.md) said the same thing: +balancing the legs at the generator is *not* the same problem as +sizing the cables, and the asymmetric solver would show us things +the balanced one structurally cannot. This doc is the first run of +that solver on a real fixture and what it actually surfaced. + +Same register as +[`07_optimiser_validation_findings.md`](./07_optimiser_validation_findings.md): +numbers, the takeaways they justify, and an explicit list of what +the numbers do *not* support. + +## What was run + +- **Fixture**: `tests/fixtures/2026-05-14_martin_modified.geojson` — + 2026 east-grid export, with the four cable resizes from + doc 11 (one 1P 16 A trunk → 3P 125 A, the GoJ feeder → 3P 63 A, + the other generator trunk → 3P 125 A, the curious-creatures feed + → 3P 32 A) and the asymmetric `curious creatures` modelled as + `phase: [1, 2]`. +- **Strategies**: Strategy D (greedy least-loaded leg) and Strategy + B (round-robin), both from `pp_interop/phases`, applied fresh + each run. +- **Solvers**: balanced `pp.runpp` (`compute_power_flow`) and + asymmetric `pp.runpp_3ph` (`compute_power_flow_3ph`), in parallel. +- **Usage factor**: 0.5× is the working assumption (doc 10). The + scale sweep walks down from full nameplate to expose where each + solver tips into non-convergence. + +The zero-sequence cable and source defaults +(`R0_OVER_R1 = 4`, `X0_OVER_X1 = 4`, `SOURCE_X0X_MAX = 1`, +`SOURCE_R0X0_MAX = 0.1`) are engineering rule-of-thumb, not +measured — anything sensitive to them is a *direction*, not a +calibrated number. + +## Convergence sweep + +| Strategy | Scale | Balanced `runpp` | Asymmetric `runpp_3ph` | +|---|---:|---|---| +| greedy | 1.0× | converged, worst 21.7 % (`jamhouse`) | **no converge** | +| greedy | 0.7× | converged, worst 13.9 % (`jamhouse`) | converged, worst leg **37.7 %** (`oasis playground` L2) | +| greedy | 0.5× | converged, worst 9.5 % (`jamhouse`) | converged, worst leg **21.1 %** (`oasis playground` L2) | +| greedy | 0.3× | converged, worst 5.5 % (`jamhouse`) | converged, worst leg **11.3 %** (`oasis playground` L2) | +| round-robin | 1.0× | converged, worst 21.7 % (`jamhouse`) | **no converge** | +| round-robin | 0.7× | converged, worst 13.9 % (`jamhouse`) | **no converge** | +| round-robin | 0.5× | converged, worst 9.5 % (`jamhouse`) | converged, worst leg **31.2 %** (`jamhouse` L2) | +| round-robin | 0.3× | converged, worst 5.5 % (`jamhouse`) | converged, worst leg **14.7 %** (`jamhouse` L2) | + +Three things to read from this. + +### 1. The balanced view is optimistic in a way that hides physical reality + +At full nameplate the balanced solve converges on both strategies +and reports a 21.7 % worst-node drop — alarming, but at least a +*finite answer*. The asymmetric solve refuses to converge at all. +The grid as exported, with realistic per-leg loads on its worst +phase, **does not have an AC operating point**. The balanced 21.7 % +is a fiction produced by averaging that load across three legs; +under the real per-leg loading the heaviest phase is past its P–V +nose. + +At 0.5× — the working usage factor — the gap is smaller but still +load-bearing: balanced says 9.5 %, asymmetric says **21.1 %**. +Roughly 2.2× higher, and on a *different node* (`oasis playground`, +not `jamhouse`). A planner working from the balanced number would +think they were in the 10 % drop zone and would not order bigger +cable. The asymmetric number is comfortably over the 5 % NF C +15-100 ceiling. The two solvers are answering different questions. + +### 2. Strategy choice changes which scale converges + +Greedy converges at 0.7×; round-robin does not. Same grid, same +loads, only the phase plan differs. This is the convergence-side +manifestation of what doc 10 already noted: greedy levels the +generator legs to 0.6 % imbalance vs round-robin's 9.5 %, and that +extra margin buys roughly one scale step before the heaviest leg +runs out of voltage. It is not a free lunch — see point 3 — but it +is a real result. + +### 3. Strategy choice does **not** rescue voltage drop + +At 0.5× both strategies converge, but the worst leg is still 21 % +(greedy) or 31 % (round-robin), far above 5 %. Greedy beats +round-robin here, but neither even approaches spec. This confirms +the doc 10 conclusion from a second angle: phase planning is for +generator-leg balance and convergence margin; it is **not a +substitute for cable sizing**. The cables on `oasis playground`'s +path are 2.5 mm² flex, and no amount of phase shuffling fixes a +cable that is too thin for its load. + +## What `runpp_3ph` surfaces that nothing else does + +### Per-leg voltage at the worst-affected stalls + +The top five buses by worst-leg drop, greedy @ 0.5×: + +| Node | L1 % | L2 % | L3 % | Voltage unbalance % | +|---|---:|---:|---:|---:| +| `oasis playground` | 6.7 | **21.1** | 2.2 | 3.1 | +| `desert dessert` | -1.4 | -5.9 | **19.5** | 3.5 | +| `planet pulpo` | 7.6 | **19.1** | 2.4 | 2.7 | +| `mirage` | 8.3 | **17.6** | 2.6 | 2.5 | +| `jamhouse` | **16.5** | 14.0 | -0.1 | 2.7 | + +Three things to notice. The worst-leg drop varies by node by 5+ +percentage points within the same fixture. The *quietest* leg at a +bus can sit slightly above nominal (`oasis playground` L3 at -1.4 V, +i.e. 230 V + tiny). And the voltage-unbalance factor at downstream +buses (2.5-3.5 %) is far higher than the 0.05 % the *generator* +sees — a single number for the whole grid would have hidden every +one of these. + +### Neutral currents per cable — the cable-spec result + +Top five cables by neutral current, greedy @ 0.5×: + +| Cable | Spec | Phases (A) | Neutral (A) | Notes | +|---|---|---|---:|---| +| `cable_23` | 3P 125 A trunk | 50.8 / 40.7 / 15.3 | 29.5 | within rating | +| `cable_21` | 3P 63 A | 45.6 / 33.0 / 15.3 | 24.7 | within rating | +| **`cable_15`** | **1P 16 A, 2.5 mm²** | 0 / 0 / 20.4 | **20.4** | **neutral over rating** | +| `cable_1` | 3P 125 A trunk | 27.1 / 35.6 / 45.6 | 16.0 | within rating | +| `cable_22` | 3P 63 A | 26.0 / 33.0 / 15.3 | 13.4 | within rating | + +`cable_15` (zaz → `desert dessert`) carries 20.4 A on a 2.5 mm² / 16 +A flex — over rating, **on the neutral specifically**. A balanced +solve cannot see this: in its world the neutral carries nothing. +Reduced-section-neutral cables (`3G6+½N`) would fare worse still on +this branch. + +This is the cable-spec result we hoped for. It will not be alarming +on every fixture, but it does say "neutral sizing is *not* free" — +a worst-case neutral approaching the worst-phase current can show +up on a real grid, not just on a stress fixture. + +### Strategy comparison under `runpp_3ph` (not just tree-walk) + +Doc 10 measured strategy quality with nopywer's tree walk (max-phase +rule). With `runpp_3ph` we can measure it under the real AC solve: + +| Metric @ 0.5× | Greedy | Round-robin | +|---|---:|---:| +| Worst leg vdrop | **21.1 %** | 31.2 % | +| Worst leg location | `oasis playground` L2 | `jamhouse` L2 | +| Worst neutral current | 29.5 A | 24.0 A | +| Worst node unbalance | ~3.5 % | (similar order) | +| Lowest converging scale | 0.7× | 0.5× | + +Greedy wins on convergence margin and on worst-leg drop. Round-robin +is marginally better on worst-neutral. The neutral-current ordering +is a nuance worth flagging: greedy concentrates more of its +imbalance onto the upstream trunk (`cable_23`) where the path is +heaviest, while round-robin's positional cycling spreads things +more uniformly across cables. Greedy's lower *generator* imbalance +does not propagate as lower *per-cable* neutral everywhere. + +## Generator-balanced is not grid-balanced + +Greedy at 0.5× gives a generator-bus voltage unbalance of **0.05 %** +— effectively perfect. Three nodes downstream of the same generator +sit at **3+ %** unbalance. That is the asymmetric reality the +balanced view cannot represent at all: the balance metric reported +by `print_grid_info` and used by the strategy itself is a +generator-side quantity, and it does not predict what individual +stalls actually see at their socket. Same lesson as doc 10's +worst-vdrop result, but now in the dimension of voltage unbalance +rather than per-leg drop. + +## What this does *not* show + +- **Real zero-sequence parameters.** The 4× rule for `r0`/`x0` and + the `Yn` source defaults are engineering rules of thumb. The + numbers above will shift if a particular event uses an isolated + (IT) genset or differently-built flex; the *directions* (greedy + > round-robin, balanced < asymmetric on worst-drop, some cables + see meaningful neutral current) are robust, the absolute values + are not calibrated. +- **What the 2026 grid was actually wired as.** The greedy plan we + ran is a synthetic phase assignment, not a measurement. It is a + *plausible* assignment with the right structure to expose the + mechanisms; it is not a reconstruction of what the + electricians did. A historical reconstruction (doc 10 + Strategy A) would replace it. +- **A spec verdict on `cable_15`.** It is over rating *on the + neutral* under this phase plan at 0.5× usage. That is enough to + flag, not enough to condemn the cable — a different plan, or a + measurement of actual usage at the load, could put it back inside + rating. The point is that without `runpp_3ph` the question was + invisible. + +## Headlines + +1. **Balanced `runpp` is optimistic, sometimes catastrophically so.** + On this fixture at full nameplate it converges to 21.7 % worst + drop; the asymmetric solve says the grid has no operating point + at all. At 0.5× it under-states the worst drop by ~2.2×. +2. **Phase strategy matters for convergence, not for drop.** Greedy + wins one scale step of margin over round-robin and lower worst + drops, but neither comes close to 5 %. Cable sizing is the only + lever that does (doc 11). +3. **Neutral current can put a cable over rating even when its phase + loading is fine.** `cable_15` is the first concrete example we + have of the cable-spec question doc 10 raised. The number that + answers it does not exist in any other tool we have. +4. **Generator-leg balance is decoupled from downstream unbalance.** + Greedy gets the generator to 0.05 % unbalance; individual loads + downstream see 3+ %. Reporting only the generator number would + misrepresent what the grid does at the socket. diff --git a/research/pandapower/13_cable_costs.md b/research/pandapower/13_cable_costs.md new file mode 100644 index 0000000..d009e01 --- /dev/null +++ b/research/pandapower/13_cable_costs.md @@ -0,0 +1,179 @@ +# Cable cost fits for the optimiser + +Nopywer's optimiser currently uses dimensionless cable tier costs: + +| Repo class | Phases | Connector rating | Section | +|------------|--------|------------------|---------| +| `Cable16A` | 1P | 16 A | 2.5 mm2 | +| `Cable32A` | 3P | 32 A | 6 mm2 | +| `Cable63A` | 3P | 63 A | 16 mm2 | +| `Cable125A`| 3P | 125 A | 35 mm2 | + +Those classes are used as a planning abstraction, but the optimiser's +objective is meant to represent a real-world purchasing cost. A useful +market model is therefore: + +```text +finished_cable_price_eur = a + b * length_m +``` + +where the price is for a complete extension lead: flexible rubber cable, +plug, socket/coupler, assembly, testing, VAT where listed, and shop +margin. Shipping is excluded. + +This is deliberately **not** a raw-cable-plus-connectors bill of +materials. A 100 m cable with ends attached is a different product from +100 m of loose cable and two connector parts; finished-lead listings are +the better evidence for the optimiser. + +## Recommended fits + +Ordinary least squares fits over complete-lead listings give: + +| Repo class | Market equivalent | Fitted price model | +|------------|-------------------|--------------------| +| `Cable16A` | CEE 230 V 16 A, H07RN-F 3G2.5 | `24.57 + 2.22 * length_m` | +| `Cable32A` | CEE 400 V 32 A, H07RN-F 5G6 | `59.32 + 5.48 * length_m` | +| `Cable63A` | CEE 400 V 63 A, H07RN-F 5G16 | `166.22 + 20.87 * length_m` | +| `Cable125A`| CEE 400 V 125 A, H07RN-F 5G35 | `149.00 + 50.00 * length_m` | + +The intercept is not just connector cost. It includes the fixed part of +retail pricing: termination labour, testing, SKU handling, minimum +margin, and short-length pricing effects. The slope is the observed +extra cost per added metre in a finished product line. + +## Source observations + +### `Cable16A`: 230 V / 16 A / H07RN-F 3G2.5 + +Source: ENECEN, "CEE-Verlaengerungskabel 230V/16A IP44 Gummi +H07RN-F 3x2,5 mm2 mit ST/KU 3-polig". + + + +| Length | Listed price | +|-------:|-------------:| +| 5 m | 35.68 EUR | +| 10 m | 46.80 EUR | +| 25 m | 80.13 EUR | +| 40 m | 113.48 EUR | +| 50 m | 135.70 EUR | + +Fit: + +```text +price_eur = 24.57 + 2.22 * length_m +``` + +### `Cable32A`: 400 V / 32 A / H07RN-F 5G6 + +Source: Lecos-Elektronik, "CEE 32A Starkstrom Verlaengerungskabel +5x6mm2 H07RN-F". + + + +| Length | Listed price | +|-------:|-------------:| +| 10 m | 115.90 EUR | +| 15 m | 144.90 EUR | +| 25 m | 189.00 EUR | +| 50 m | 335.90 EUR | + +Fit: + +```text +price_eur = 59.32 + 5.48 * length_m +``` + +### `Cable63A`: 400 V / 63 A / H07RN-F 5G16 + +Source: Lecos-Elektronik, "CEE 63A Starkstrom Verlaengerungskabel +5x16mm2 H07RN-F". + + + +| Length | Listed price used | +|-------:|------------------:| +| 10 m | 339.00 EUR | +| 15 m | 499.00 EUR | +| 20 m | 599.00 EUR | +| 25 m | 699.00 EUR | +| 50 m | 1199.00 EUR | + +Fit: + +```text +price_eur = 166.22 + 20.87 * length_m +``` + +One wider search also surfaced a Lecos category page snapshot with lower +63 A prices for 25 m and 50 m (`599.00 EUR`, `999.00 EUR`). Using that +variant instead gives: + +```text +price_eur = 237.35 + 15.40 * length_m +``` + +The recommended fit above keeps the originally observed product-line +points because they were internally monotonic and closer to the specific +5G16 listing found first. Treat the 63 A slope as shop- and date-sensitive. + +### `Cable125A`: 400 V / 125 A / H07RN-F 5G35 + +Source: Lecos-Elektronik, "CEE-Verlaengerung 125A 5G35 H07RN-F". + + + +| Length | Listed price | +|-------:|-------------:| +| 5 m | 399.00 EUR | +| 10 m | 649.00 EUR | +| 15 m | 899.00 EUR | + +Fit: + +```text +price_eur = 149.00 + 50.00 * length_m +``` + +This is only a three-point fit, but the points are exactly linear. It is +adequate for optimiser weighting. Larger 125 A products are more likely +to be quote-priced, so the exact slope should be revisited before using +it for procurement. + +## Relation to current `tier_cost` + +The current tier costs in `models.py` are: + +| Repo class | Current `tier_cost` | Fitted slope relative to 16 A | +|------------|--------------------:|------------------------------:| +| `Cable16A` | 1.0 | 1.0 | +| `Cable32A` | 3.0 | 2.5 | +| `Cable63A` | 8.0 | 9.4 | +| `Cable125A`| 20.0 | 22.5 | + +So the existing heuristic is directionally reasonable: + +- 32 A is slightly over-weighted compared with these finished-lead + examples. +- 63 A is close enough for topology decisions, though the market data + is noisy. +- 125 A is also close, especially if the optimiser only needs relative + pressure against long heavy trunks. + +The important improvement is that a real purchase model has a non-zero +fixed term. A pure `length * tier_cost` objective slightly over-penalises +long light cables relative to many short heavy cables, because it ignores +the per-lead overhead. + +## Caveats + +- Prices were gathered from live EU web listings in May 2026 and can + change without notice. +- VAT treatment follows the listing presentation; shipping is excluded. +- Listings are mostly German/Austrian EU retail examples, not Spanish + supplier quotes. +- Connector family, IP rating, brand, and phase inverter options can move + the fixed cost substantially. +- These formulas are for cost weighting in planning, not procurement or + electrical compliance. diff --git a/research/pandapower/README.md b/research/pandapower/README.md index 2f0a017..95227df 100644 --- a/research/pandapower/README.md +++ b/research/pandapower/README.md @@ -47,8 +47,39 @@ Date of research: April 2026. Pandapower latest at time of writing: **3.4.0** 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. + data; the small synthetic does), four strategies for + synthesising phase data (B round-robin and D greedy now + implemented in `pp_interop/phases`), and a per-layer scope + breakdown. Key finding: phase balancing and voltage-drop relief + are decoupled levers — greedy cuts leg imbalance 9.5 %→0.6 % but + the worst node's drop barely moves (13.74 %→13.70 %). + - [`10.1_three_phase_theory.md`](10.1_three_phase_theory.md) — the + physics behind doc 10: why a balanced solve collapses 3 phases + to 1, symmetrical components, why the zero sequence *is* the + neutral, and the worked puzzle of why a single-phase load + unbalances the two legs it doesn't touch (with a verification + experiment proving it is real physics, not a solver quirk). +11. [`11_field_export_fixture_walkthrough.md`](11_field_export_fixture_walkthrough.md) — + how to plug the `2026-05-14_martin.geojson` field export into + the pandapower code. Two gaps: a trivial schema mismatch (export + keys `area_mm2`/`plugs_and_sockets_a` vs input keys + `area`/`plugs&sockets`, loads silently to defaults), and a real + one — at full load the grid is past voltage collapse and `runpp` + will not converge. The non-convergence is itself the finding. +12. [`12_runpp_3ph_findings.md`](12_runpp_3ph_findings.md) — first run + of the asymmetric solver on the 2026 modified fixture. + Convergence sweep, worst-leg drop, neutral-current per cable, and + strategy comparison under the real AC solve. Headlines: balanced + `runpp` is optimistic (worst drop ~2.2× lower than `runpp_3ph` at + 0.5× usage, and *converges* at full load where `runpp_3ph` does + not have an AC operating point at all); phase strategy buys + convergence margin but not cable-spec compliance; one cable goes + over rating on its *neutral* under a plausible phase plan. +13. [`13_cable_costs.md`](13_cable_costs.md) — EU web research on finished + CEE extension-lead costs for nopywer's four cable tiers. Fits each tier + as `a + b * length_m` from complete H07RN-F leads rather than raw cable + plus separate connector parts, and compares the fitted slopes with the + current `tier_cost` heuristic. ## TL;DR diff --git a/scripts/run_asymmetric.py b/scripts/run_asymmetric.py new file mode 100644 index 0000000..602635e --- /dev/null +++ b/scripts/run_asymmetric.py @@ -0,0 +1,277 @@ +"""End-to-end asymmetric three-phase analysis on a GeoJSON fixture. + +Loads the fixture, snaps cables, optionally plans phases for unphased +loads, runs `runpp_3ph`, and prints a per-leg / per-cable report. The +companion to `phase_geojson` for the *solve* side: nothing here that +you couldn't assemble from `pp_interop` primitives in 30 lines of +Python, but assembled so a reviewer can run + + uv run python scripts/run_asymmetric.py tests/fixtures/.geojson + +and see what the asymmetric solver makes of the grid without writing +any code. + +The balanced `runpp` is also run for comparison so you can read the +"balanced says X, asymmetric says Y" gap that doc 12 documents. + +============================================================ +The single user-facing knob — `--load-factor` +============================================================ + +`--load-factor` is **one** multiplier on nameplate that drives the +entire analysis. The default (0.5) is the project-wide festival +diversity assumption: "festival loads draw ~50 % of nameplate on +average". The number says, *for this run*, what fraction of +nameplate we are modelling. + +It has two consistent effects: + + 1. **Planning (`--plan`)** — `assign_greedy` / `assign_round_robin` + balance the legs against `power_watts × load_factor`. A + borderline-large load shifts in or out of the "fits on one leg" + category, and greedy's heaviest-first pack order is computed on + scaled values. + 2. **Solve (`runpp` and `runpp_3ph`)** — every load's + `power_watts` is multiplied by `load_factor` before conversion, + so both solvers see the same scaled demand the planner assumed. + +The two effects use the **same number** on purpose. Plan-then-stress- +test (plan at 0.5, solve at 1.0) is a separate diagnostic workflow, +not a knob on this script — if you genuinely need it, run the +script twice or write a 5-line scripted sweep. + +What changing it does, intuitively: + + - `--load-factor 1.0` — model worst case: every load peaks + simultaneously. The most conservative answer. Often won't + converge on real fixtures (the grid is past voltage collapse). + - `--load-factor 0.5` (default) — model the festival diversity + assumption. The number doc 12's findings use. + - `--load-factor 0.2` — model a deep quiet period. Usually + converges with plenty of headroom; useful for sanity-checking + that the topology is sensible. + +============================================================ +Other notes +============================================================ + + - `runpp_3ph` will refuse to converge on grids past voltage collapse + (the P-V nose). This is a *physical* result, not a script bug — + see research/pandapower/11_field_export_fixture_walkthrough.md. + - Phase planning is **optional**. Without `--plan`, unphased loads + stay `phase=None`, become balanced `pp.load`s under `runpp_3ph`, + and the asymmetric solver behaves much like the balanced one + (because the grid genuinely *is* balanced then). Pass + `--plan greedy` to actually exercise the asymmetric machinery. + +============================================================ +Order of operations +============================================================ + + load -> snap -> plan (sees power_watts × load_factor) + -> apply -> scale (multiply power_watts by load_factor) + -> convert -> solve (balanced and asymmetric) + +The scale step happens *after* planning so the planner can use the +unmodified `node.power_watts` field to compute candidates and the +scaling is the one place that mutates the grid. +""" + +import argparse +import logging +import sys +from pathlib import Path + +from nopywer.analyze import _snap_cables_to_nodes +from nopywer.io import load_geojson +from nopywer.models import PowerGrid +from nopywer.pp_interop import ( + compute_power_flow, + compute_power_flow_3ph, + config, + to_pandapower, + to_pandapower_3ph, +) +from nopywer.pp_interop.phases import ( + apply_assignment, + assign_greedy, + assign_round_robin, +) + + +def _positive_float(raw: str) -> float: + """argparse type — reject zero, negative, and absurd factors.""" + value = float(raw) + if value <= 0: + raise argparse.ArgumentTypeError(f"--load-factor must be > 0, got {value!r}") + if value > 5: + raise argparse.ArgumentTypeError( + f"--load-factor {value!r} is implausibly large (> 5x nameplate). " + "If you really mean this, override the bound in scripts/run_asymmetric.py." + ) + return value + + +_STRATEGIES = {"greedy": assign_greedy, "round_robin": assign_round_robin} + + +def _nameplate_total_w(grid: PowerGrid) -> float: + """Sum of nameplate power across non-generator loads, in watts.""" + return sum(n.power_watts for n in grid.nodes.values() if not n.is_generator) + + +def _scale_loads_in_place(grid: PowerGrid, factor: float) -> None: + """Multiply every non-generator load's nameplate power by `factor`. + + Mutates `node.power_watts` directly. Done after planning so the + planner sees unmodified nameplate; done before conversion so the + solver sees the scaled values. + """ + for node in grid.nodes.values(): + if not node.is_generator: + node.power_watts *= factor + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=( + "Asymmetric 3-phase analysis on a nopywer GeoJSON fixture. " + "See the module docstring for what --load-factor does." + ), + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument("fixture", type=Path, help="Input GeoJSON file.") + parser.add_argument( + "--plan", + choices=("none", "greedy", "round_robin"), + default="none", + help=( + "Synthesise phase assignments for unphased loads before " + "solving. Without this, runpp_3ph treats unphased loads " + "as balanced and behaves like runpp." + ), + ) + parser.add_argument( + "--load-factor", + type=_positive_float, + default=config.DEFAULT_USAGE_FACTOR, + help=( + "Single multiplier on nameplate applied to BOTH phase " + "planning AND the solver inputs. 1.0 = model worst-case " + "(everyone peaks); 0.5 (default) = festival diversity " + "assumption; 0.2 = deep quiet period. See module docstring." + ), + ) + parser.add_argument( + "--top", + type=int, + default=5, + help="How many worst-leg buses / worst-neutral cables to print.", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Show DEBUG-level log records from nopywer modules.", + ) + args = parser.parse_args(argv) + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.WARNING, + format="%(levelname)-7s %(name)-45s %(message)s", + ) + logging.getLogger("pandapower").setLevel(logging.WARNING) + logging.getLogger("numba").setLevel(logging.WARNING) + + if not args.fixture.exists(): + print(f"fixture not found: {args.fixture}", file=sys.stderr) + return 2 + + # --- load + snap ------------------------------------------------- + nodes, cables = load_geojson(args.fixture) + grid = PowerGrid(nodes=nodes, cables=cables) + _snap_cables_to_nodes(grid) + + nameplate_w = _nameplate_total_w(grid) + effective_w = nameplate_w * args.load_factor + + # --- pre-flight summary — print what assumptions are in play ---- + print("=" * 64) + print(f"fixture: {args.fixture}") + print(f"loaded: {len(grid.nodes)} nodes, {len(grid.cables)} cables") + print(f"nameplate total: {nameplate_w / 1e3:7.2f} kW (sum of all loads)") + print(f"load factor: {args.load_factor:7.2f} (applied to BOTH planning and solve)") + print(f"effective total: {effective_w / 1e3:7.2f} kW (what the solvers will see)") + print(f"plan strategy: {args.plan}") + print("=" * 64) + + # --- optional phase planning (sees nameplate × load_factor) ----- + if args.plan != "none": + plan = _STRATEGIES[args.plan](grid, usage_factor=args.load_factor) + apply_assignment(grid, plan) + print(f"\nphase plan ({args.plan}):") + print(f" loads assigned: {len(plan.phases)}") + print( + f" balance: {plan.balance_pct:.2f}% (0 % = perfectly level; lower is better)" + ) + print( + f" per-leg totals: " + f"L1={plan.leg_totals_w[0] / 1e3:.2f} kW, " + f"L2={plan.leg_totals_w[1] / 1e3:.2f} kW, " + f"L3={plan.leg_totals_w[2] / 1e3:.2f} kW" + ) + + # --- scale loads in place before conversion --------------------- + if args.load_factor != 1.0: + _scale_loads_in_place(grid, args.load_factor) + + # --- balanced runpp (reference) --------------------------------- + print("\n=== balanced runpp (for comparison) ===") + pp_balanced = to_pandapower(grid) + try: + bal = compute_power_flow(pp_balanced) + worst_bus, worst_drop = max(bal.bus_vdrop_percent.items(), key=lambda kv: kv[1]) + print(f" converged: {bal.converged}") + print(f" worst bus drop: {worst_bus!r} = {worst_drop:.2f}%") + except Exception as exc: + print(f" did NOT converge: {type(exc).__name__}: {exc}") + print(" (grid past voltage collapse — try a lower --load-factor)") + + # --- asymmetric runpp_3ph --------------------------------------- + print("\n=== runpp_3ph (asymmetric) ===") + pp_3ph = to_pandapower_3ph(grid) + n_bal = len(pp_3ph.balanced_load_idx) + n_asym = len(pp_3ph.asymmetric_load_idx) + print(f" loads in net.load (balanced): {n_bal:3d}") + print(f" loads in net.asymmetric_load (per-leg): {n_asym:3d}") + + try: + res = compute_power_flow_3ph(pp_3ph) + except Exception as exc: + print(f" did NOT converge: {type(exc).__name__}: {exc}") + print(" (grid past voltage collapse — try a lower --load-factor)") + return 1 + + print(f" converged: {res.converged}") + + worst_vdrop = sorted( + ( + (name, leg + 1, drop) + for name, legs in res.bus_vdrop_percent.items() + for leg, drop in enumerate(legs) + ), + key=lambda t: -t[2], + )[: args.top] + print(f"\n top {args.top} worst per-leg voltage drops:") + for name, leg, drop in worst_vdrop: + print(f" {name!r:30s} L{leg} {drop:6.2f} %") + + worst_neutral = sorted(res.line_neutral_current_a.items(), key=lambda kv: -kv[1])[: args.top] + print(f"\n top {args.top} cables by neutral current:") + for cable_id, i_n in worst_neutral: + print(f" {cable_id!r:20s} {i_n:6.2f} A") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/nopywer/io.py b/src/nopywer/io.py index 9a3bf0b..dd39aba 100644 --- a/src/nopywer/io.py +++ b/src/nopywer/io.py @@ -10,6 +10,56 @@ logger = logging.getLogger(__name__) +# A round-tripped nopywer export uses different property keys from the +# hand-authored input schema (e.g. `area_mm2` vs `area`). Map each export +# key onto its input-schema equivalent so either form loads. +_EXPORT_KEY_ALIASES = { + "area_mm2": "area", + "plugs_and_sockets_a": "plugs&sockets", + "length_m": "length", +} + +# Property keys we read from features after alias normalisation. Anything +# outside this set is either a nopywer-export computed field (silently +# ignored, the loader doesn't need it) or genuinely unknown (worth a +# DEBUG line so a fixture author can spot a typo). Keep in sync with +# what `load_geojson` actually reads. +_KNOWN_FEATURE_KEYS: frozenset[str] = frozenset( + { + # input-schema keys read by the loader + "name", + "power", + "phase", + "area", + "plugs&sockets", + "length", + # widely-used metadata we ignore but recognise + "type", + "id", + "from", + "to", + "nodes", + # nopywer-export computed fields (output of `PowerNode.to_geojson` + # / `Cable.to_geojson`) — silently ignored on re-load + "power_watts", + "cum_power_watts", + "cum_power", # legacy export key (pre-2026); kept for back-compat + "voltage", + "vdrop_percent", + "i_sc_ka", + "distro", + "cable_type", + "current_a", + "cum_power_kw", + "vdrop_volts", + } +) + + +def _normalise_keys(props: dict) -> dict: + """Return props with export-schema keys renamed to input-schema keys.""" + return {_EXPORT_KEY_ALIASES.get(k, k): v for k, v in props.items()} + def load_geojson(source: str | Path | dict) -> tuple[dict[str, PowerNode], dict[str, Cable]]: """Parse a GeoJSON FeatureCollection (file path or dict). @@ -28,9 +78,20 @@ def load_geojson(source: str | Path | dict) -> tuple[dict[str, PowerNode], dict[ for feature in fc.get("features", []): geom = feature.get("geometry", {}) - props = feature.get("properties", {}) + raw_props = feature.get("properties", {}) + props = _normalise_keys(raw_props) gtype = geom.get("type", "") + unknown = set(props) - _KNOWN_FEATURE_KEYS + if unknown: + logger.debug( + "Feature %r carries property keys not used by load_geojson: %s. " + "If one of these is a typo for a known key the value will be " + "silently ignored.", + props.get("name") or props.get("id") or "", + sorted(unknown), + ) + if gtype == "Point": name = (props.get("name") or "").strip().lower() if not name: @@ -49,7 +110,22 @@ def load_geojson(source: str | Path | dict) -> tuple[dict[str, PowerNode], dict[ ) if isinstance(phase, int) and 1 <= phase <= 3: node.power_per_phase[phase - 1] = power + elif isinstance(phase, list) and phase: + # Multi-phase load: split power evenly across the listed legs + # (e.g. phase=[1, 2] on a 10 kW load -> 5 kW on L1, 5 kW on L2). + legs = [p for p in phase if isinstance(p, int) and 1 <= p <= 3] + for leg in legs: + node.power_per_phase[leg - 1] += power / len(legs) else: + if isinstance(phase, str): + logger.warning( + "Node %r has legacy string phase marker %r; treating " + "as unphased (balanced across L1/L2/L3). Strip these " + "markers from the fixture once the sub-grid reporting " + "they came from is no longer needed.", + name, + phase, + ) node.power_per_phase += power / 3 nodes.append(node) diff --git a/src/nopywer/models.py b/src/nopywer/models.py index 17006ca..d25571d 100644 --- a/src/nopywer/models.py +++ b/src/nopywer/models.py @@ -41,22 +41,28 @@ def __setattr__(self, name, value): super().__setattr__(name, value) def to_geojson(self) -> dict: + properties = { + "name": self.name, + "type": "generator" if self.is_generator else "load", + "power_watts": round(float(self.power_per_phase.sum()), 1), + "cum_power_watts": round(float(self.cum_power.sum()), 1), + "voltage": self.voltage, + "vdrop_percent": self.vdrop_percent, + "i_sc_ka": self.i_sc_ka, + "distro": self.distro, + } + # Omit `phase` when unset so unphased exports stay clean; emit it + # whenever a planner (manual or `pp_interop.phases`) has assigned + # one, so the choice round-trips through load_geojson. + if self.phase is not None: + properties["phase"] = self.phase return { "type": "Feature", "geometry": { "type": "Point", "coordinates": [self.lon, self.lat], }, - "properties": { - "name": self.name, - "type": "generator" if self.is_generator else "load", - "power_watts": round(float(self.power_per_phase.sum()), 1), - "cum_power_watts": round(float(self.cum_power.sum()), 1), - "voltage": self.voltage, - "vdrop_percent": self.vdrop_percent, - "i_sc_ka": self.i_sc_ka, - "distro": self.distro, - }, + "properties": properties, } diff --git a/src/nopywer/pp_interop/README.md b/src/nopywer/pp_interop/README.md new file mode 100644 index 0000000..f922fc5 --- /dev/null +++ b/src/nopywer/pp_interop/README.md @@ -0,0 +1,333 @@ +# `pp_interop` — pandapower interop layer + +Optional feature. Install with `uv sync --extra pandapower`. Without +the extra, every public entry point raises a clear `ImportError` +pointing at this command. + +Two solver paths live here: + +- **Balanced AC power flow** (`to_pandapower` → `compute_power_flow`), + built on pandapower's `runpp`. One scalar voltage per bus, one + scalar current per cable. Right model for a well-balanced grid. +- **Asymmetric three-phase power flow** (`to_pandapower_3ph` → + `compute_power_flow_3ph`), built on `runpp_3ph`. Per-leg voltages + and currents, plus the neutral-conductor current — the quantity + no balanced tool and no nopywer tree walk can produce. + +A separate `phases/` subpackage synthesises per-load phase +assignments for fixtures that arrive unphased or partially phased. +The balanced solver always ignores `phase`. The asymmetric solver +*uses* it where it's set and falls back to a balanced draw where +it isn't — so phase planning is an enhancement to the 3ph path, +not a prerequisite. + +See `research/pandapower/` (docs 10, 10.1, 11, 12) for the modelling +theory and findings behind these choices. + +## Code-flow diagram + +``` + ┌─────────────────────────────┐ + │ GeoJSON FeatureCollection │ + │ (input or export schema) │ + └──────────────┬──────────────┘ + │ + ▼ + ┌────────────────────────────────────┐ + │ nopywer.io.load_geojson(path) │ + │ → (dict[name, PowerNode], │ + │ dict[id, Cable]) │ + └──────────────┬─────────────────────┘ + │ + (wrap in PowerGrid / analyze) + │ + ▼ + ┌──────────────────────┐ + │ PowerGrid │ + │ (snapped cables, │ + │ phase fields set │ + │ per node) │ + └────────┬─────────────┘ + │ + ┌───────────────────┴─── optional ────────────────┐ + │ phase-planning pass (only for fixtures whose │ + │ loads arrive `phase=None`) │ + │ │ + │ from nopywer.pp_interop import config │ + │ from nopywer.pp_interop.phases import ( │ + │ assign_greedy, assign_round_robin, │ + │ apply_assignment) │ + │ │ + │ plan = assign_greedy( │ + │ grid, │ + │ usage_factor=config.DEFAULT_USAGE_FACTOR) │ + │ apply_assignment(grid, plan) │ + └───────────────────┬─────────────────────────────┘ + │ + ┌─────────────────┴─────────────────┐ + │ │ + ▼ ▼ + ┌────────────────────────┐ ┌──────────────────────────────┐ + │ to_pandapower(grid) │ │ to_pandapower_3ph(grid) │ + │ → PandapowerGrid │ │ → Pandapower3phGrid │ + │ (balanced loads in │ │ (independent net; loads │ + │ net.load) │ │ split across net.load and │ + │ │ │ net.asymmetric_load by │ + │ │ │ node.phase) │ + └──────────┬─────────────┘ └────────────────┬─────────────┘ + │ │ + ▼ ▼ + ┌────────────────────────┐ ┌────────────────────────────┐ + │ compute_power_flow │ │ compute_power_flow_3ph │ + │ (runpp, balanced) │ │ (runpp_3ph) │ + └──────────┬─────────────┘ └────────────────┬───────────┘ + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌────────────────────────────┐ + │ PowerFlowResults │ │ PowerFlow3phResults │ + │ (scalar per-bus V, │ │ (per-leg tuples + neutral │ + │ per-line A, │ │ current, unbalance %) │ + │ loading %) │ │ │ + └──────────────────────┘ └────────────────────────────┘ +``` + +## Inputs + +| What | Required form | +|---|---| +| **GeoJSON** | A `FeatureCollection`. Points → `PowerNode`s with `name`, `power` (W), `phase` (`None` / `1` / `2` / `3` / `[1,2]` / `"U"` / `"Y"`), `is_generator`. LineStrings → cables with `id`, `length`/`length_m`, `area`/`area_mm2`, `plugs&sockets`/`plugs_and_sockets_a`. Either the hand-authored or the round-tripped export schema works (aliases handled in `io._EXPORT_KEY_ALIASES`). | +| **Generator** | Exactly one node with `is_generator: true`. Its bus becomes the `ext_grid` slack. | +| **Cable endpoints** | Cables must reference existing node names via `from_node` / `to_node`. The GeoJSON loader populates these by snapping; the converter raises `ValueError` if any are missing. | +| **Per-load phase** | Only required for the **asymmetric** path. For balanced `to_pandapower` it's ignored. For `to_pandapower_3ph`, any load whose `phase` is an `int` or `list` becomes an `asymmetric_load`; `None` stays balanced. Legacy `"U"` / `"Y"` string markers have no electrical meaning and are coerced to balanced with a `WARNING` log line — see Logging below. Use the `phases` package to synthesise an assignment if your fixture arrives unphased. | +| **`usage_factor`** | **Required, keyword-only**, on every public function in `pp_interop.phases`. There is no default. Use `config.DEFAULT_USAGE_FACTOR` (0.5) as the project-wide reference if you want it. | +| **Modelling constants** | All in `pp_interop/config.py`: generator rating (`GEN_SN_KVA`, `GEN_XDSS_PU`, `GEN_RX`), cable reactance (`X_OHM_PER_KM`), zero-sequence ratios (`R0_OVER_R1`, `X0_OVER_X1`), source earthing (`SOURCE_X0X_MAX`, `SOURCE_R0X0_MAX`), and `DEFAULT_USAGE_FACTOR`. All have working defaults. Generator and cable values are also exposed as kwargs on `to_pandapower(...)` / `to_pandapower_3ph(...)` for per-call overrides; zero-sequence and source-earthing values are read directly from `config` at conversion time and must be changed there. | +| **Optional dep** | `uv sync --extra pandapower` — without it, `_import_pandapower` raises a clear `ImportError`. | + +## Outputs + +| Solver | Returns | Key fields | +|---|---|---| +| `compute_power_flow` | `PowerFlowResults` | `bus_voltage_v[node]` (scalar V_PN), `bus_vdrop_percent[node]`, `line_current_a[cable]`, `line_loading_percent[cable]`, `converged` | +| `compute_power_flow_3ph` | `PowerFlow3phResults` | `bus_voltage_v[node]` (3-tuple per leg), `bus_vdrop_percent[node]` (3-tuple), `bus_unbalance_percent[node]`, `line_current_a[cable]` (3-tuple), **`line_neutral_current_a[cable]`** (the headline number), `line_loading_percent[cable]`, helpers `worst_phase_vdrop()` / `worst_neutral_current()` | +| `compare_with_tree_walk` | `TreeWalkVsAcDiff` | Side-by-side of nopywer's tree walk and balanced `runpp` on the same grid, with per-bus / per-line diffs and helper `worst_*_disagreement` methods. | + +## Minimal runnable examples + +**Balanced power flow** — exactly the shape `tests/test_pp_interop.py` +uses: + +```python +from nopywer.io import load_geojson +from nopywer.models import PowerGrid +from nopywer.pp_interop import to_pandapower, compute_power_flow + +nodes, cables = load_geojson("tests/fixtures/2026-05-14_martin_modified.geojson") +grid = PowerGrid(nodes=nodes, cables=cables) +pp_grid = to_pandapower(grid) # → PandapowerGrid +results = compute_power_flow(pp_grid) # → PowerFlowResults +print(results.bus_vdrop_percent["jamhouse"]) +``` + +**Phase a fixture and write it out** — for the common workflow, +use the `phase_geojson` one-call wrapper: + +```python +from nopywer.pp_interop import config +from nopywer.pp_interop.phases import phase_geojson + +plan = phase_geojson( + "tests/fixtures/2026-05-14_martin.geojson", + "2026-05-14_martin_phased.geojson", + usage_factor=config.DEFAULT_USAGE_FACTOR, + strategy="greedy", # or "round_robin" +) +print(plan.balance_pct) +``` + +`phase_geojson` loads the input, runs the chosen strategy, applies +the assignment, and writes a pretty-printed GeoJSON to the output +path. `usage_factor` is required (no default — the right value is +event-specific). The source fixture is never modified in place. + +The same workflow done by hand — useful when you want to inspect +or compare plans before writing, or to use the strategies from +inside larger pipelines: + +```python +import json +from nopywer.io import load_geojson +from nopywer.models import PowerGrid +from nopywer.pp_interop import config +from nopywer.pp_interop.phases import assign_greedy, apply_assignment + +nodes, cables = load_geojson("tests/fixtures/2026-05-14_martin.geojson") +grid = PowerGrid(nodes=nodes, cables=cables) + +plan = assign_greedy(grid, usage_factor=config.DEFAULT_USAGE_FACTOR) +apply_assignment(grid, plan) # writes plan.phases onto the grid + +with open("2026-05-14_martin_phased.geojson", "w") as f: + json.dump(grid.to_geojson(), f, indent=2) +``` + +`PowerNode.to_geojson` emits `phase` whenever it is non-`None` and +omits it otherwise, so the export stays clean for fully-balanced +fixtures and round-trips faithfully for planned ones. Reload the +written file with `load_geojson` and the assignment is in place — +`assign_greedy` will then treat those loads as pre-assigned and +leave them alone. The existing `python -m nopywer -o ` +CLI uses the same `grid.to_geojson()` and so picks up the phase +field automatically. + +**Asymmetric power flow from the command line** — for a quick run +without writing code, `scripts/run_asymmetric.py` wraps the whole +pipeline behind a single `--load-factor` knob that drives both +planning and solve consistently. See `scripts/run_asymmetric.py`'s +module docstring for the rationale behind the one-knob design and +the implications of each value. + +```bash +uv run python scripts/run_asymmetric.py \ + tests/fixtures/2026-05-14_martin_modified.geojson \ + --load-factor 0.5 +``` + +**Asymmetric power flow with phase planning** — the full doc 12 +pipeline: + +```python +from nopywer.io import load_geojson +from nopywer.models import PowerGrid +from nopywer.pp_interop import ( + config, + to_pandapower_3ph, + compute_power_flow_3ph, +) +from nopywer.pp_interop.phases import assign_greedy, apply_assignment + +nodes, cables = load_geojson("tests/fixtures/2026-05-14_martin_modified.geojson") +grid = PowerGrid(nodes=nodes, cables=cables) + +# 1. plan phases (only if the fixture has unphased loads) +plan = assign_greedy(grid, usage_factor=config.DEFAULT_USAGE_FACTOR) +apply_assignment(grid, plan) + +# 2. convert + solve asymmetric +pp_grid_3ph = to_pandapower_3ph(grid) +results = compute_power_flow_3ph(pp_grid_3ph) + +print(results.worst_phase_vdrop()) # ('jamhouse', 2, 13.7) +print(results.worst_neutral_current()) # ('cable_15', 20.4) +``` + +## Two converters, two grid types + +`to_pandapower` and `to_pandapower_3ph` return **distinct dataclasses** +and produce **independent** `pandapowerNet` objects. They share a +private skeleton builder (buses, lines, ext_grid), but neither +mutates the other's output. Converting the same `PowerGrid` for +both flows is a no-shared-state operation. + +| Converter | Return type | Loads in net | +|---|---|---| +| `to_pandapower(grid)` | `PandapowerGrid` | Every load in `net.load`; one `load_idx` map. | +| `to_pandapower_3ph(grid)` | `Pandapower3phGrid` | Loads split across `net.load` and `net.asymmetric_load`; **two** index maps: `balanced_load_idx` and `asymmetric_load_idx`. | + +### Why the 3ph grid splits loads into two tables + +The split is not an implementation quirk — it tracks a real +distinction in the source data: + +- **`phase is None`** → load goes to `net.load`, keyed in + `balanced_load_idx`. The planner has not declared a leg; nopywer + treats this as an even three-phase draw, which is exactly what + pandapower's `pp.load` means under `runpp_3ph`. Using `pp.load` + here is not a shortcut — it's the right primitive. +- **`phase` is an `int` or `list`** → load goes to + `net.asymmetric_load`, keyed in `asymmetric_load_idx`. The + planner has nailed the load to specific legs. Only + `pp.asymmetric_load` lets us tell pandapower "8 kW on L1, 0 on + L2, 0 on L3" or "5 kW on L1, 5 kW on L2, 0 on L3". + +A load name appears in **at most one** map. The split is decided at +conversion time from `node.phase` and is fixed for the lifetime of +the `Pandapower3phGrid`. + +### What `phase = None` means at each layer + +`None` is not "missing data" — it is the explicit modelling choice +"balanced three-phase draw". Each layer treats it consistently with +that meaning: + +| Layer | Behaviour on `phase is None` | +|---|---| +| `io.load_geojson` | Sets `node.power_per_phase = [P/3, P/3, P/3]` for any tree-walk analysis downstream. | +| `PowerNode.to_geojson` | **Omits** the `"phase"` key (not emitted as `null`). Reload is symmetric: missing key → `None`. | +| `phases.single_phase_candidates` | `None` is the marker for "eligible to be planned". Any other `phase` (int / list / string) is treated as already-decided and left alone. | +| `phases.apply_assignment` | When a plan assigns a leg, sets `node.phase = 1\|2\|3`. Does **not** recompute `power_per_phase` — that derived cache is stale until the grid is reloaded. The `phase` field is authoritative. | +| `to_pandapower` (balanced) | Ignores `phase` entirely; every load is a balanced `pp.load`. | +| `to_pandapower_3ph` (asymmetric) | `None` → `pp.load` in `balanced_load_idx`. `pp.runpp_3ph` treats `pp.load` as an even three-phase draw, so this is the correct primitive for unphased loads, not a workaround. | +| `compute_power_flow_3ph` | An unphased load sags its three legs equally and contributes zero to neutral current (its three currents cancel in the return). Indistinguishable from a balanced motor. | + +The takeaway: a fully-unphased grid solves through `runpp_3ph` just +fine — it'll just look very similar to the balanced solve, because +that's what an all-balanced grid genuinely is. + +### What this means for you + +- **Convert once per flow.** Build a `PandapowerGrid` for the + balanced solve; build a separate `Pandapower3phGrid` for the + asymmetric solve. The two are independent — modifying one does + not affect the other. +- **`compute_power_flow_3ph` requires `Pandapower3phGrid`.** The + type-checker will catch "I passed the balanced grid to the 3ph + solver" at the call site rather than letting it fail at runtime + with a cryptic pandapower error. +- **To check whether a specific load has been planned**, look at + `pp_grid_3ph.asymmetric_load_idx` (or equivalently + `name in pp_grid_3ph.balanced_load_idx`). You do not need to + inspect `net` directly — the type surfaces the planning state. +- **A fully-planned fixture leaves `balanced_load_idx` empty.** Same + code path; no special handling. Likewise, a fully-unphased fixture + leaves `asymmetric_load_idx` empty and `runpp_3ph` solves a + balanced grid (the same one `runpp` would solve, plus the + zero-sequence overhead). +- **Write-back keyed on names is safe.** Iterating + `pp_grid_3ph.bus_idx` reaches every node exactly once regardless + of the load split. + +## Logging + +Three silent data coercions emit log records so they cannot regress +to silent behaviour. Configure the standard `logging` module to see +them (`logging.basicConfig(level=logging.DEBUG)` for everything; +`level=logging.WARNING` for just the loud ones). + +| Logger | Level | When it fires | +|---|---|---| +| `nopywer.io` | `WARNING` | A node's `phase` is a string (legacy `"U"` / `"Y"` sub-grid marker). The loader treats it as unphased and balances the load across all three legs. | +| `nopywer.io` | `DEBUG` | A feature carries property keys outside the known set — useful for catching typos (`powr` for `power`) without being noisy on legitimate computed export fields. | +| `nopywer.pp_interop._powerflow_3ph` | `WARNING` | `to_pandapower_3ph` sees a string-marker `phase` on a load. The load is routed to the balanced `pp.load` table (not `pp.asymmetric_load`) since the marker has no per-leg semantics. | +| `nopywer.pp_interop.phases._common` | `WARNING` | The phase-planning leg seed sees a string-marker `phase`. Treated as unphased in the leg totals; same justification as above. | + +The string-marker warnings are operator-actionable: they signal that +a legacy reporting marker propagated through to the modelling layer. +Strip the markers from the fixture once the sub-grid reporting they +came from is no longer needed. + +## Things to remember + +- `usage_factor` is required everywhere in `pp_interop.phases`. + Forgetting it is a `TypeError`, by design — the right value is + event-specific and we don't want it disappearing into a default. +- Voltages are reported in nopywer-native phase-to-neutral volts + (≈ 230 V nominal), not pandapower's line-to-line per-unit. +- For real-cable safety calls the headline number from the + asymmetric path is `line_neutral_current_a` — it's the one no + balanced solve can give you and the one that decides + reduced-vs-full-section neutral cable choice. +- Multi-phase loads (`phase=[1, 2]`) split power **evenly** across + the listed legs. This is a modelling assumption — see + `research/pandapower/10_three_phase_asymmetric_modelling.md` + ("Modelling assumption — multi-phase loads self-balance evenly") + for when it holds and when it doesn't. diff --git a/src/nopywer/pp_interop/__init__.py b/src/nopywer/pp_interop/__init__.py index 722a711..3f113b2 100644 --- a/src/nopywer/pp_interop/__init__.py +++ b/src/nopywer/pp_interop/__init__.py @@ -4,10 +4,16 @@ Public API: to_pandapower(grid, ...) -> PandapowerGrid + to_pandapower_3ph(grid, ...) -> Pandapower3phGrid compute_short_circuit(pp_grid) -> {name: i_sc_ka} compute_power_flow(pp_grid) -> PowerFlowResults + compute_power_flow_3ph(pp_grid) -> PowerFlow3phResults compare_with_tree_walk(grid, ...) -> TreeWalkVsAcDiff +The two converters return distinct dataclasses (`PandapowerGrid` vs +`Pandapower3phGrid`) and produce independent `pandapowerNet`s — no +shared state, neither mutates the other's output. + All defaults and modelling assumptions live in `config.py`. """ @@ -19,15 +25,25 @@ compare_with_tree_walk, compute_power_flow, ) +from ._powerflow_3ph import ( + Pandapower3phGrid, + PowerFlow3phResults, + compute_power_flow_3ph, + to_pandapower_3ph, +) from ._shortcircuit import compute_short_circuit __all__ = [ + "Pandapower3phGrid", "PandapowerGrid", + "PowerFlow3phResults", "PowerFlowResults", "TreeWalkVsAcDiff", "compare_with_tree_walk", "compute_power_flow", + "compute_power_flow_3ph", "compute_short_circuit", "config", "to_pandapower", + "to_pandapower_3ph", ] diff --git a/src/nopywer/pp_interop/_conversion.py b/src/nopywer/pp_interop/_conversion.py index 105da7c..a38d56f 100644 --- a/src/nopywer/pp_interop/_conversion.py +++ b/src/nopywer/pp_interop/_conversion.py @@ -1,8 +1,18 @@ """PowerGrid → pandapowerNet conversion. -Used by every calc function in this package. Produces a `PandapowerGrid` -holding the net, name→bus_idx map, name→load_idx map, and a back-ref -to the source `PowerGrid` for write-back. +Used by every calc function in this package. Two parallel converters +share a private skeleton builder: + + - `to_pandapower` (balanced) — returns `PandapowerGrid` with balanced + `pp.load`s. For `compute_power_flow` and `compute_short_circuit`. + - `to_pandapower_3ph` (asymmetric) — lives in `_powerflow_3ph.py`; + returns `Pandapower3phGrid` with loads split across `net.load` and + `net.asymmetric_load`. For `compute_power_flow_3ph`. + +The two converters do **not** share state. Each call produces a fresh, +independent `pandapowerNet` so the same source `PowerGrid` can be +converted for either flow without one converter's output affecting +the other. """ import math @@ -33,11 +43,15 @@ def _import_pandapower(): @dataclass class PandapowerGrid: - """A `PowerGrid` translated into pandapower form. + """A `PowerGrid` translated into pandapower form for *balanced* flow. Produced once by `to_pandapower(grid)` and passed to calc functions - (`compute_short_circuit`, `compute_power_flow`, ...). The same handle - is reusable across multiple calc calls. + (`compute_short_circuit`, `compute_power_flow`). The same handle is + reusable across multiple calc calls. + + For asymmetric three-phase flow see `Pandapower3phGrid` in + `_powerflow_3ph.py` — a different dataclass because the load layout + in `net` is genuinely different (balanced + asymmetric tables). Attributes: net: the `pandapowerNet`. Mutable — calc functions write into @@ -45,6 +59,8 @@ class PandapowerGrid: bus_idx: maps `PowerNode.name` to its pandapower bus index. load_idx: maps `PowerNode.name` to its pandapower load index (only present for non-generator nodes with power > 0). + Every load in this grid is in `net.load` — there is no + asymmetric companion table. source: the original `PowerGrid`. Calc functions write results back onto its `PowerNode`s using `bus_idx`. """ @@ -55,40 +71,44 @@ class PandapowerGrid: load_idx: dict[str, int] = field(default_factory=dict) -def to_pandapower( +def _build_net_skeleton( grid: PowerGrid, + *, gen_sn_kva: float = config.GEN_SN_KVA, gen_xdss_pu: float = config.GEN_XDSS_PU, gen_rx: float = config.GEN_RX, x_ohm_per_km: float = config.X_OHM_PER_KM, - load_pf: float = PF, -) -> PandapowerGrid: - """Translate a `PowerGrid` into a `PandapowerGrid`. +) -> tuple[pandapowerNet, dict[str, int]]: + """Build the bus/line/ext_grid scaffold shared by both converters. - Cables must already have `from_node` and `to_node` populated (set by - `io.load_geojson` or by `analyze._snap_cables_to_nodes`). + Loads are deliberately not added here — they're the one part that + differs between balanced (`to_pandapower`) and asymmetric + (`to_pandapower_3ph`) flow. Zero-sequence line parameters are also + not added here (the 3ph converter writes them itself); a balanced + net has them absent, a 3ph net has them populated. 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. + grid: the source `PowerGrid`. Must have at least one cable and + every cable's `from_node` / `to_node` must reference a node + present in `grid.nodes`. + 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 + Returns: + A `(net, bus_idx)` pair. `net` is a fresh `pandapowerNet` with + the empty load table; `bus_idx` maps `PowerNode.name` to bus + index for use by the caller when it adds loads. - Each non-generator node with `power_watts > 0` becomes a balanced - `pp.load` with P = power_watts and Q derived from `load_pf`. Loads - are needed for `runpp` (power flow) and are harmless for IEC 60909 - `calc_sc` max-case (which standardly ignores prefault load). + Raises: + ValueError: if the grid has no cables, or a cable has missing + or unknown endpoints. + ImportError: if pandapower is not installed. """ if not grid.cables: raise ValueError("At least one cable is required") @@ -110,20 +130,6 @@ def to_pandapower( rx_max=gen_rx, ) - load_idx: dict[str, int] = {} - tan_phi = math.tan(math.acos(load_pf)) if 0 < load_pf < 1 else 0.0 - for name, node in grid.nodes.items(): - if node.is_generator or node.power_watts <= 0: - continue - p_mw = node.power_watts / 1e6 - load_idx[name] = pp.create_load( - net, - bus=bus_idx[name], - p_mw=p_mw, - q_mvar=p_mw * tan_phi, - name=name, - ) - for cable_id, cable in grid.cables.items(): if not cable.from_node or not cable.to_node: raise ValueError( @@ -150,4 +156,69 @@ def to_pandapower( name=cable_id, ) + return net, bus_idx + + +def to_pandapower( + grid: PowerGrid, + gen_sn_kva: float = config.GEN_SN_KVA, + gen_xdss_pu: float = config.GEN_XDSS_PU, + gen_rx: float = config.GEN_RX, + x_ohm_per_km: float = config.X_OHM_PER_KM, + load_pf: float = PF, +) -> PandapowerGrid: + """Translate a `PowerGrid` into a `PandapowerGrid` for *balanced* flow. + + Cables must already have `from_node` and `to_node` populated (set by + `io.load_geojson` or by `analyze._snap_cables_to_nodes`). + + For asymmetric three-phase flow use `to_pandapower_3ph` in + `_powerflow_3ph.py` instead — it builds an independent net, not a + mutation of this one. + + 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 + + Each non-generator node with `power_watts > 0` becomes a balanced + `pp.load` with P = power_watts and Q derived from `load_pf`. Loads + are needed for `runpp` (power flow) and are harmless for IEC 60909 + `calc_sc` max-case (which standardly ignores prefault load). + """ + pp, _ = _import_pandapower() + net, bus_idx = _build_net_skeleton( + grid, + gen_sn_kva=gen_sn_kva, + gen_xdss_pu=gen_xdss_pu, + gen_rx=gen_rx, + x_ohm_per_km=x_ohm_per_km, + ) + + load_idx: dict[str, int] = {} + tan_phi = math.tan(math.acos(load_pf)) if 0 < load_pf < 1 else 0.0 + for name, node in grid.nodes.items(): + if node.is_generator or node.power_watts <= 0: + continue + p_mw = node.power_watts / 1e6 + load_idx[name] = pp.create_load( + net, + bus=bus_idx[name], + p_mw=p_mw, + q_mvar=p_mw * tan_phi, + name=name, + ) + return PandapowerGrid(net=net, bus_idx=bus_idx, source=grid, load_idx=load_idx) diff --git a/src/nopywer/pp_interop/_powerflow_3ph.py b/src/nopywer/pp_interop/_powerflow_3ph.py new file mode 100644 index 0000000..cfa8af9 --- /dev/null +++ b/src/nopywer/pp_interop/_powerflow_3ph.py @@ -0,0 +1,410 @@ +"""Asymmetric (unbalanced three-phase) AC power flow. + +The balanced sibling, `_powerflow.py`, runs `pp.runpp` — every load +is an even three-phase draw and the neutral carries nothing. That is +the right model for a well-balanced grid, and a deliberate +simplification everywhere else. + +This module runs `pp.runpp_3ph`, which solves each phase +independently and the neutral-return path explicitly. It is the only +way to see two things a balanced solve structurally cannot: + + - **per-leg voltage drop** — when L1 carries 6 kW and L3 carries + 1 kW, the loads on L1 sag further. The balanced model averages + that away. + - **neutral-conductor current** — the imbalance returns through + the neutral. This is the number that decides whether a + reduced-section-neutral cable (`3G6+½N`) is safe or whether the + run needs full-section neutral (`4G6`). No balanced tool, and + no nopywer tree walk, surfaces it at all. + +== What it needs that the balanced path does not == + +`runpp_3ph` needs zero-sequence parameters — how the cables and the +source behave to the imbalance current. These have no nopywer +analogue and are not on festival flex spec sheets, so they come from +engineering rule-of-thumb defaults in `config.py` +(`R0_OVER_R1`, `X0_OVER_X1`, `SOURCE_X0X_MAX`, `SOURCE_R0X0_MAX`). +See research doc 10 for the reasoning behind the numbers. + +`to_pandapower_3ph` builds an **independent** pandapower net via the +shared `_build_net_skeleton` (which `to_pandapower` also uses). It +does **not** mutate the balanced converter's output. The two paths +are sibling consumers of the same skeleton, so converting the same +`PowerGrid` for both flows is a no-shared-state operation: each call +produces a fresh net and a fresh result object. + +The returned `Pandapower3phGrid` keeps the dual load layout visible +in the type: `balanced_load_idx` for loads that stayed in `net.load` +(those with `phase is None`), `asymmetric_load_idx` for loads in +`net.asymmetric_load` (those with an explicit `phase`). Callers can +tell which table a load is in without re-inspecting the net. + +Voltage convention: identical to `_powerflow.py`. pandapower returns +per-unit on `vn_kv` (line-to-line); we report nopywer's +phase-to-neutral volts (`vm_pu * vn_kv * 1000 / sqrt(3)`). + +== What this module does not (yet) do == + +There is no `compare_3ph_with_tree_walk`. nopywer's tree walk +collapses each node to a single scalar voltage (via the max-phase +current rule), so it has no per-leg voltage to line up against +`runpp_3ph`'s three. A meaningful comparison is a findings-doc +exercise (doc 12), not a drop-in mirror of `compare_with_tree_walk`. +""" + +import logging +import math +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from ..constants import PF, V0 +from ..models import PowerGrid +from . import config +from ._conversion import _build_net_skeleton, _import_pandapower + +if TYPE_CHECKING: + from pandapower.auxiliary import pandapowerNet +else: + pandapowerNet = Any + +logger = logging.getLogger(__name__) + + +@dataclass +class Pandapower3phGrid: + """A `PowerGrid` translated for *asymmetric* three-phase power flow. + + Produced by `to_pandapower_3ph(grid)` and consumed by + `compute_power_flow_3ph`. A distinct dataclass from + `PandapowerGrid` because the load layout in `net` is genuinely + different: loads split across two pandapower tables. + + Attributes: + net: the `pandapowerNet`, with zero-sequence line parameters + and source vector-group fields populated. Mutable — + `compute_power_flow_3ph` writes into `net.res_*_3ph`. + bus_idx: maps `PowerNode.name` to its pandapower bus index. + Same shape and meaning as on `PandapowerGrid`. + balanced_load_idx: maps `PowerNode.name` to its index in + `net.load`. Contains the loads with `phase is None` — + `runpp_3ph` treats a plain `pp.load` as an even + three-phase draw, so balanced loads stay in the balanced + table. + asymmetric_load_idx: maps `PowerNode.name` to its index in + `net.asymmetric_load`. Contains the loads with an + explicit `phase` (int or list) — these go to + `pp.asymmetric_load` with per-leg P / Q. + source: the original `PowerGrid`. Kept as a back-reference + so callers debugging a converted grid can trace any + bus/load row back to the nopywer node it came from + without re-running the conversion. Not read or written + by `compute_power_flow_3ph` itself. + + A given load name appears in **at most one** of + `balanced_load_idx` / `asymmetric_load_idx`. The split is decided + at conversion time from `node.phase` and is fixed for the + lifetime of this grid object. + """ + + net: pandapowerNet + bus_idx: dict[str, int] + source: PowerGrid + balanced_load_idx: dict[str, int] = field(default_factory=dict) + asymmetric_load_idx: dict[str, int] = field(default_factory=dict) + + +def _phase_split(phase: object, power_watts: float) -> tuple[float, float, float]: + """Split a load's power across (L1, L2, L3) from its `phase` field. + + Mirrors the per-phase rule used by `io.load_geojson` and + `phases._common._fixed_leg_seed`, so the conversion sees a load + exactly the way the rest of nopywer does: + + - `phase` is an int 1-3 — the whole load sits on that one leg. + - `phase` is a list, e.g. `[1, 2]` — the load is split evenly + across the listed legs (a 10 kW load on `[1, 2]` is 5 kW on + L1, 5 kW on L2, nothing on L3). + - anything else (the `"U"` / `"Y"` sub-grid markers, an empty + or malformed list, or `None`) — balanced: an even third on + each leg. + + Note on `None`: `to_pandapower_3ph` routes `phase is None` loads + straight to `pp.load` and never calls this function for them. + The `None` branch is therefore purely defensive — it exists so + `_phase_split` is correct in isolation, e.g. for tests that + parametrise across every `phase` form. + + Args: + phase: the node's `phase` field, in any of its forms. + power_watts: the load's total real power. + + Returns: + A `(p_l1, p_l2, p_l3)` tuple in watts that always sums to + `power_watts`. + """ + if isinstance(phase, int) and 1 <= phase <= 3: + return tuple(power_watts if phase == i + 1 else 0.0 for i in range(3)) + if isinstance(phase, list): + legs = [p for p in phase if isinstance(p, int) and 1 <= p <= 3] + if legs: + share = power_watts / len(legs) + return tuple(share if i + 1 in legs else 0.0 for i in range(3)) + third = power_watts / 3 + return (third, third, third) + + +def to_pandapower_3ph( + grid: PowerGrid, + *, + load_pf: float = PF, + **skeleton_kwargs: object, +) -> Pandapower3phGrid: + """Convert a `PowerGrid` for asymmetric flow — fresh net, no mutation. + + Builds an **independent** pandapower net via the shared + `_build_net_skeleton` (which the balanced `to_pandapower` also + uses), then adds the 3ph-only pieces: + + 1. **Zero-sequence line params** — `r0`, `x0`, `c0` on every + line, derived from the positive-sequence values via the + `R0_OVER_R1` / `X0_OVER_X1` config ratios. + 2. **Source vector group** — `r0x0_max` / `x0x_max` on the + ext_grid, describing the genset's zero-sequence return path. + 3. **Loads in their right table** — each non-generator load is + classified by `node.phase`: + + - `phase is None` → balanced: a `pp.load` in `net.load`, + keyed by name in `balanced_load_idx`. `runpp_3ph` + treats this as an even three-phase draw. + - explicit `int` or `list` → asymmetric: a + `pp.asymmetric_load` in `net.asymmetric_load`, keyed by + name in `asymmetric_load_idx`, with per-leg P / Q from + `_phase_split`. + + No load is in both tables; the split is fixed at conversion + time. Nothing this function does touches a previously-built + `PandapowerGrid` — converting the same source grid for both + balanced and asymmetric flow produces two genuinely independent + objects. + + Args: + grid: the nopywer `PowerGrid` to convert. Its cables must + already be snapped (run `analyze` or load via GeoJSON). + load_pf: load power factor, used to derive reactive power + for both balanced and asymmetric loads. Defaults to the + global `PF`. + **skeleton_kwargs: forwarded verbatim to + `_build_net_skeleton` (generator ratings, + `x_ohm_per_km`, etc.). + + Returns: + A `Pandapower3phGrid` whose `net` is ready for + `compute_power_flow_3ph`. + + Raises: + ValueError: propagated from `_build_net_skeleton` if the + grid has no cables or a cable references an unknown node. + ImportError: if pandapower is not installed. + """ + pp, _ = _import_pandapower() + net, bus_idx = _build_net_skeleton(grid, **skeleton_kwargs) # type: ignore[arg-type] + + # zero-sequence line parameters + net.line["r0_ohm_per_km"] = net.line["r_ohm_per_km"] * config.R0_OVER_R1 + net.line["x0_ohm_per_km"] = net.line["x_ohm_per_km"] * config.X0_OVER_X1 + net.line["c0_nf_per_km"] = config.C0_NF_PER_KM + + # source vector group / earthing + net.ext_grid["r0x0_max"] = config.SOURCE_R0X0_MAX + net.ext_grid["x0x_max"] = config.SOURCE_X0X_MAX + + # loads, each into the right table by node.phase + balanced_load_idx: dict[str, int] = {} + asymmetric_load_idx: dict[str, int] = {} + tan_phi = math.tan(math.acos(load_pf)) if 0 < load_pf < 1 else 0.0 + for name, node in grid.nodes.items(): + if node.is_generator or node.power_watts <= 0: + continue + # Legacy "U" / "Y" string markers have no electrical meaning; + # treat them the same as `phase is None` (balanced). Logged so + # the operator knows the marker propagated through to a solve. + is_balanced = node.phase is None or isinstance(node.phase, str) + if isinstance(node.phase, str): + logger.warning( + "Node %r carries legacy string phase marker %r; routing to " + "balanced pp.load. Phase markers should be int (1/2/3) or " + "list ([1, 2]) for runpp_3ph to use them.", + name, + node.phase, + ) + if is_balanced: + p_mw = node.power_watts / 1e6 + balanced_load_idx[name] = pp.create_load( + net, + bus=bus_idx[name], + p_mw=p_mw, + q_mvar=p_mw * tan_phi, + name=name, + ) + else: + p_l1, p_l2, p_l3 = _phase_split(node.phase, node.power_watts) + asymmetric_load_idx[name] = pp.create_asymmetric_load( + net, + bus=bus_idx[name], + p_a_mw=p_l1 / 1e6, + p_b_mw=p_l2 / 1e6, + p_c_mw=p_l3 / 1e6, + q_a_mvar=p_l1 * tan_phi / 1e6, + q_b_mvar=p_l2 * tan_phi / 1e6, + q_c_mvar=p_l3 * tan_phi / 1e6, + name=name, + ) + + return Pandapower3phGrid( + net=net, + bus_idx=bus_idx, + source=grid, + balanced_load_idx=balanced_load_idx, + asymmetric_load_idx=asymmetric_load_idx, + ) + + +@dataclass +class PowerFlow3phResults: + """Per-leg and neutral results from `runpp_3ph`, in nopywer-native units. + + Every per-leg field is an `(L1, L2, L3)` tuple. Contrast + `PowerFlowResults` from `_powerflow.py`, whose fields are scalars + — that is the whole point of running the asymmetric solve. + + Attributes: + bus_voltage_v: phase-to-neutral volts on each leg, by node + name. `(230, 230, 230)` would be a perfectly balanced bus + at nominal. + bus_vdrop_percent: percent drop from `V0` on each leg, by node + name. The per-leg spread here is exactly what a balanced + `runpp` averages away. + bus_unbalance_percent: voltage unbalance factor (negative- / + positive-sequence magnitude, percent), by node name. 0 % + is balanced. Defined as 0.0 where pandapower returns NaN + (a bus with no load and no imbalance to measure). + line_current_a: per-leg current, by cable id. + line_neutral_current_a: neutral-conductor current, by cable + id. The quantity neither a balanced solve nor the nopywer + tree walk can give you — and the one that decides whether + a reduced-section-neutral cable is adequate. + line_loading_percent: worst-leg loading against `max_i_ka`, + by cable id (pandapower's own `loading_percent`, which is + already the max across the three legs). + converged: whether `runpp_3ph` reached a solution. + """ + + bus_voltage_v: dict[str, tuple[float, float, float]] + bus_vdrop_percent: dict[str, tuple[float, float, float]] + bus_unbalance_percent: dict[str, float] + line_current_a: dict[str, tuple[float, float, float]] + line_neutral_current_a: dict[str, float] + line_loading_percent: dict[str, float] + converged: bool + + def worst_phase_vdrop(self) -> tuple[str, int, float] | None: + """Find the single most-sagged leg anywhere on the grid. + + Returns: + `(node_name, leg, vdrop_percent)` for the worst leg, where + `leg` is 1, 2 or 3. `None` if there are no buses. + """ + if not self.bus_vdrop_percent: + return None + worst_name = "" + worst_leg = 0 + worst_drop = float("-inf") + for name, legs in self.bus_vdrop_percent.items(): + for i, drop in enumerate(legs): + if drop > worst_drop: + worst_name, worst_leg, worst_drop = name, i + 1, drop + return worst_name, worst_leg, worst_drop + + def worst_neutral_current(self) -> tuple[str, float] | None: + """Find the cable carrying the most neutral current. + + Returns: + `(cable_id, neutral_current_a)` for the worst cable, or + `None` if there are no lines. + """ + if not self.line_neutral_current_a: + return None + cid, current = max(self.line_neutral_current_a.items(), key=lambda kv: kv[1]) + return cid, current + + +def compute_power_flow_3ph(pp_grid: Pandapower3phGrid) -> PowerFlow3phResults: + """Run asymmetric AC power flow on a net from `to_pandapower_3ph`. + + Calls `pp.runpp_3ph` and translates `res_bus_3ph` / `res_line_3ph` + into nopywer-native units (volts P-N, amps, percent). Does not + mutate the source `PowerGrid`. + + Takes a `Pandapower3phGrid` specifically, not a `PandapowerGrid` + — the type-checker will catch the "I passed the balanced grid to + the 3ph solver" mistake at the call site rather than at runtime + with a cryptic pandapower error. + + Args: + pp_grid: the converted grid from `to_pandapower_3ph`. + + Returns: + A `PowerFlow3phResults` with per-leg bus voltages, per-leg and + neutral line currents, and the voltage-unbalance factor. + + Raises: + pandapower.LoadflowNotConverged: if `runpp_3ph` fails to + converge — for a festival grid this usually means it is + past voltage collapse, the same physical signal as a + failed balanced solve (see doc 11). + ImportError: if pandapower is not installed. + """ + pp, _ = _import_pandapower() + pp.runpp_3ph(pp_grid.net) + net = pp_grid.net + + converged = bool(net["converged"]) + vn_v = net.bus.iloc[0]["vn_kv"] * 1000.0 + pn_v_at_nominal = vn_v / math.sqrt(3) + + bus_voltage_v: dict[str, tuple[float, float, float]] = {} + bus_vdrop_percent: dict[str, tuple[float, float, float]] = {} + bus_unbalance_percent: dict[str, float] = {} + for name, idx in pp_grid.bus_idx.items(): + row = net.res_bus_3ph.loc[idx] + legs = tuple( + round(float(row[f"vm_{ph}_pu"]) * pn_v_at_nominal, 2) for ph in ("a", "b", "c") + ) + bus_voltage_v[name] = legs + bus_vdrop_percent[name] = tuple(round(100.0 * (V0 - v) / V0, 3) for v in legs) + unbalance = float(row["unbalance_percent"]) + # pandapower returns NaN for a bus with no load (0/0 sequence ratio). + bus_unbalance_percent[name] = round(unbalance, 3) if unbalance == unbalance else 0.0 + + line_current_a: dict[str, tuple[float, float, float]] = {} + line_neutral_current_a: dict[str, float] = {} + line_loading_percent: dict[str, float] = {} + for line_idx, line_row in net.line.iterrows(): + cable_id = line_row["name"] + res = net.res_line_3ph.loc[line_idx] + line_current_a[cable_id] = tuple( + round(float(res[f"i_{ph}_ka"]) * 1000.0, 3) for ph in ("a", "b", "c") + ) + line_neutral_current_a[cable_id] = round(float(res["i_n_ka"]) * 1000.0, 3) + line_loading_percent[cable_id] = round(float(res["loading_percent"]), 2) + + return PowerFlow3phResults( + bus_voltage_v=bus_voltage_v, + bus_vdrop_percent=bus_vdrop_percent, + bus_unbalance_percent=bus_unbalance_percent, + line_current_a=line_current_a, + line_neutral_current_a=line_neutral_current_a, + line_loading_percent=line_loading_percent, + converged=converged, + ) diff --git a/src/nopywer/pp_interop/config.py b/src/nopywer/pp_interop/config.py index 3f6f879..608aec8 100644 --- a/src/nopywer/pp_interop/config.py +++ b/src/nopywer/pp_interop/config.py @@ -99,3 +99,91 @@ # IEC 60909 case: maximum prospective short-circuit current. FAULT_CASE: str = "max" + + +# === Three-phase asymmetric flow (runpp_3ph) === +# +# Zero-sequence parameters describe how a cable and the source behave +# to the imbalance current returning through the neutral and earth — +# the current that a balanced `runpp` never sees. None of these have a +# nopywer analogue (the tree walk does not model the neutral at all) +# and they are not on festival flex spec sheets, so they are +# engineering rule-of-thumb defaults. See research doc 10. + +# Zero-sequence cable R and X as a multiple of the positive-sequence +# value. +# +# Physically, Z0 ≈ Z_phase + 3·Z_return, where Z_return is the +# impedance of the loop that the imbalance (neutral) current closes +# through. The ratio Z0/Z1 therefore depends on what conductors +# actually carry that return — which in turn depends on the cable +# build AND on the earthing topology of the wider system. The +# default 4.0 assumes the festival-typical case below; override it +# if your kit or wiring differs. See research/pandapower/10.1 for +# the derivation and sources. +# +# Assumed cable build (festival HO7RN-F 5-core flex): +# - 3 phase conductors + 1 full-section Neutral + 1 full-section +# Protective Earth, all the same copper cross-section. +# - "Full-section N" means N has the same area as a phase, so the +# return path through N alone has R_N ≈ R_phase. (A reduced- +# section neutral — common in fixed building wiring — would +# push R0/R1 closer to 5-6.) +# +# Assumed earthing topology (festival TN-S diesel genset): +# - N and PE are bonded ONLY at the generator star point. PE is +# full-section for fault-current capacity, but does not act as +# a parallel return for steady-state neutral current — it sits +# at near-zero potential and carries no imbalance current. +# - Steady-state zero-sequence current returns through the Neutral +# conductor alone, giving Z_return ≈ Z_phase and therefore +# Z0 ≈ Z_phase + 3·Z_phase = 4·Z_phase → R0/R1 ≈ 4. +# +# How to choose a different value: +# - **Reduced-section neutral** (half-section N, no PE return): +# Z_return ≈ 2·Z_phase → R0/R1 ≈ 7. Rare on festival flex. +# - **N and PE bonded at every distro** (mesh-earthed or +# repeatedly-bonded TN-C-S): the two full-section conductors +# act as a parallel return, Z_return ≈ Z_phase/2 → R0/R1 ≈ 2.5. +# Check the site earthing scheme before assuming this. +# - **IT system** (isolated neutral, no source bond): no zero- +# sequence return at all; runpp_3ph cannot solve and the source +# earthing constants below need overriding too. +# +# X0/X1 is the same number by convention but for a different +# reason: reactance is set by conductor geometry (spacing, twist, +# return-loop area enclosed), not by cross-section. The 3-5× +# range for X0/X1 is the established LV-cable rule of thumb (IEC +# 60909-2 cable data tables); 4.0 is the mid-range default. It +# does not move when you change R0/R1 unless you have a measured +# value for the specific cable. +R0_OVER_R1: float = 4.0 +X0_OVER_X1: float = 4.0 + +# Zero-sequence capacitance per km. Negligible at LV under 1 km, same +# as the positive-sequence `C_NF_PER_KM`. +C0_NF_PER_KM: float = 0.0 + +# Source vector group / earthing. Most festival diesel gen-sets are +# TN-S with a solidly grounded star point (Yn), giving a +# low-impedance zero-sequence return: X0/X1 ~ 1, R0/X0 ~ 0.1. An +# isolated-neutral (IT) genset would have no zero-sequence return at +# all and needs these overridden. +SOURCE_X0X_MAX: float = 1.0 +SOURCE_R0X0_MAX: float = 0.1 + + +# === Phase distribution === + +# Project-wide reference usage factor. Festival loads almost never +# draw nameplate power all at once; phase-balancing on nameplate +# would over-fit to a peak that never happens. 0.5x is the +# deliberately conservative starting assumption documented in doc 10. +# +# This is **not** a default on any function in `pp_interop.phases` — +# every public entry point requires `usage_factor` to be passed +# explicitly so the choice is always visible at the call site. The +# constant exists only so callers (scripts, tests, notebooks) can +# adopt the project-wide value with one symbol rather than re- +# typing 0.5. +DEFAULT_USAGE_FACTOR: float = 0.5 diff --git a/src/nopywer/pp_interop/phases/__init__.py b/src/nopywer/pp_interop/phases/__init__.py new file mode 100644 index 0000000..66dc202 --- /dev/null +++ b/src/nopywer/pp_interop/phases/__init__.py @@ -0,0 +1,46 @@ +"""Per-load phase distribution strategies. + +Fixtures exported before phase planning was decided ship with every +load unphased. Pandapower's asymmetric `runpp_3ph` (and nopywer's own +per-phase tree walk) need each single-phase load to declare a leg. +This package synthesises that assignment. + +Two strategies, deliberately kept in separate modules: + + - `assign_round_robin` (Strategy B, `_round_robin.py`) — cyclic + L1/L2/L3 down the load list. Positional, ignores load size. + - `assign_greedy` (Strategy D, `_greedy.py`) — size-aware; each + load goes to whichever leg is currently lightest. + +Both return a `PhaseAssignment` and do not mutate the grid; call +`apply_assignment` to write the chosen plan back. All modelling +assumptions (usage factor, single-phase capacity) live in `_common`. + +For the common "load a fixture, phase it, write it out" workflow +see `phase_geojson` (`_export.py`) — a single-call wrapper around +the whole pipeline aimed at scripts and the CLI. Internal callers +should keep using the individual primitives so every step is +visible at the call site. +""" + +from ..config import DEFAULT_USAGE_FACTOR +from ._common import ( + SINGLE_PHASE_CAPACITY_W, + PhaseAssignment, + apply_assignment, + single_phase_candidates, +) +from ._export import phase_geojson +from ._greedy import assign_greedy +from ._round_robin import assign_round_robin + +__all__ = [ + "DEFAULT_USAGE_FACTOR", + "SINGLE_PHASE_CAPACITY_W", + "PhaseAssignment", + "apply_assignment", + "assign_greedy", + "assign_round_robin", + "phase_geojson", + "single_phase_candidates", +] diff --git a/src/nopywer/pp_interop/phases/_common.py b/src/nopywer/pp_interop/phases/_common.py new file mode 100644 index 0000000..4aeadb4 --- /dev/null +++ b/src/nopywer/pp_interop/phases/_common.py @@ -0,0 +1,310 @@ +"""Shared scaffolding for per-load phase distribution strategies. + +A festival grid that will be solved with pandapower's asymmetric +`runpp_3ph` benefits from every single-phase load declaring which +leg (L1 / L2 / L3) it sits on. The strategies in this package +synthesise that assignment for fixtures that ship unphased or +partially phased. + +Everything the individual strategies have in common lives here: + + - **candidate selection** (`single_phase_candidates`) — which + loads get a single leg vs which stay multi-phase (too big for a + 1-phase connection, or already carrying an explicit `phase`). + - the **leg-balance metric** (`build_assignment`) — used to score + and compare strategies. + - the **write-back** (`apply_assignment`) — the one place a plan + actually touches the grid. + +A strategy is a function +`(PowerGrid, *, usage_factor: float) -> PhaseAssignment`. It does +not mutate the grid; call `apply_assignment` to write the result +back. The `usage_factor` (festival diversity factor — loads rarely +draw nameplate together) is required at every call site; the +project-wide reference value lives in +`pp_interop.config.DEFAULT_USAGE_FACTOR`. + +== The data model it bridges == + +`PowerNode.phase` is the field every strategy ultimately drives. It +is deliberately polymorphic (see `nopywer.models`): + + - `None` — unphased. `io.load_geojson` spreads the load + evenly across all three legs (balanced). + - `1` / `2` / `3` — single-phase: the whole load sits on that leg. + - `[1, 2]` — multi-phase: the load is split evenly across the + listed legs (a 10 kW load on `[1, 2]` is 5 kW on + L1, 5 kW on L2). + - `"U"` / `"Y"` — sub-grid reporting markers with no electrical + meaning; treated here the same as `None`. + +A strategy only ever *creates* the single-int form, and only for +loads that arrive as `None`. Loads that already carry a `1/2/3`, +a list, or a string marker are left exactly as they are — the +planner (or a previous run) has spoken. +""" + +import logging +from dataclasses import dataclass + +from ...constants import PF, V0 +from ...models import Cable16A, PowerGrid + +logger = logging.getLogger(__name__) + +# Most power a single-phase 16 A connection can carry: I x V x PF. +# A load whose effective (usage-adjusted) power exceeds this cannot +# sit on one leg and is left multi-phase. Derived from the catalogue +# (`Cable16A.max_current_a`) and the global voltage / power-factor +# constants so it tracks any change to those rather than hard-coding +# ~3.3 kW. +SINGLE_PHASE_CAPACITY_W = Cable16A.max_current_a * V0 * PF + + +@dataclass(frozen=True) +class PhaseAssignment: + """The result of running a phase-distribution strategy. + + Immutable: a strategy returns one of these describing *what it + would do*; nothing changes on the grid until `apply_assignment` + is called. This split keeps strategies pure and comparable — you + can run both `assign_round_robin` and `assign_greedy` on the same + grid, inspect the two `balance_pct` values, and only then commit + one. + + Attributes: + phases: maps `PowerNode.name` to its assigned leg (1, 2 or 3). + Only single-phase *candidate* loads appear here. Loads + left multi-phase — balanced (`None`), already carrying an + explicit `phase`, or too big for one leg — are deliberately + absent, not present with a sentinel value. So + `name in assignment.phases` is the precise test for "this + strategy moved this load". + usage_factor: the nameplate fraction used for balancing (see + `config.DEFAULT_USAGE_FACTOR`). Recorded so a `PhaseAssignment` + is self-describing — the leg totals below only make sense + against the factor they were computed with. + leg_totals_w: effective watts on (L1, L2, L3) *after* this + assignment, including the contribution of loads not in + `phases`: balanced loads spread evenly across all three + legs, pre-assigned loads landed on their declared legs. + This is the whole grid's predicted per-leg loading, not + just the candidates' — that is what makes it a fair + balance figure. + balance_pct: leg imbalance as `100 * std / mean` of + `leg_totals_w`. 0 % is a perfectly level three-way split; + higher is worse. Uses the same formula as + `io.print_grid_info` so the number is directly comparable + to what the planner already sees in grid summaries. When + the grid has no load at all the mean is 0 and this is + defined as 0.0. + """ + + phases: dict[str, int] + usage_factor: float + leg_totals_w: tuple[float, float, float] + balance_pct: float + + +def single_phase_candidates(grid: PowerGrid, *, usage_factor: float) -> list[tuple[str, float]]: + """Loads eligible for a single-leg assignment. + + A load is a candidate only when *all* of the following hold: + + 1. it draws power — `power_watts > 0`. Zero-power transit nodes + (distros, junctions) have no leg to balance and are skipped. + 2. it is not the generator — the source is not a load. + 3. it has no explicit `phase` yet — `phase is None`. A load + already carrying `1/2/3`, a `[...]` list, or a `"U"/"Y"` + marker has been decided elsewhere and is left untouched. + 4. it fits on one leg — its *effective* power + (`power_watts * usage_factor`) is within + `SINGLE_PHASE_CAPACITY_W`. A load bigger than a single 16 A + connection can carry must stay multi-phase; forcing it onto + one leg would be a connection that cannot physically exist. + + Loads failing (4) are intentionally *not* returned and so are + never single-phased by a strategy — they remain `None` (balanced) + and the planner is expected to wire them three-phase. + + Args: + grid: the grid to inspect. Not mutated. + usage_factor: **required**, keyword-only. Assumed fraction of + nameplate power loads draw together. Has no safe default + because the right value is event-specific (audio shows + with simultaneous peaks differ from food courts with + spread loads); see `config.DEFAULT_USAGE_FACTOR` for the + project-wide reference figure (0.5) callers can adopt + explicitly. Scales the effective power used for the + capacity test in (4): at 0.5x a 6 kW nameplate load is + treated as 3 kW and so *is* a single-phase candidate; at + 1.0x the same load is 6 kW and is not. + + Returns: + `(name, effective_watts)` pairs in grid iteration order. + `effective_watts` is already usage-adjusted, so callers can + sum or sort on it directly without re-applying the factor. + Order is grid order; strategies that care about size (greedy) + re-sort, strategies that care about position (round-robin) + rely on it. + """ + candidates: list[tuple[str, float]] = [] + for name, node in grid.nodes.items(): + if node.is_generator or node.power_watts <= 0: + continue + if node.phase is not None: + continue + effective_w = node.power_watts * usage_factor + if effective_w <= SINGLE_PHASE_CAPACITY_W: + candidates.append((name, effective_w)) + return candidates + + +def _fixed_leg_seed(grid: PowerGrid, candidate_names: set[str], usage_factor: float) -> list[float]: + """Per-leg watts contributed by loads that are *not* being distributed. + + Before a strategy places its candidates it needs to know the leg + loading it is starting from — the grid is rarely a blank slate. + Three kinds of load contribute to this seed: + + - **balanced / unphased** (`phase is None`, but excluded from + the candidate set because it is too big for one leg): spread + evenly, `effective_w / 3` onto each of L1, L2, L3. + - **explicit single phase** (`phase` is an int 1-3): the whole + `effective_w` lands on that one leg. + - **explicit multi-phase** (`phase` is a list, e.g. `[1, 2]`): + `effective_w` split evenly across the listed legs. + + Generators, zero-power nodes, and anything in `candidate_names` + are skipped — candidates are the strategy's job, not the seed's. + A `"U"` / `"Y"` string marker falls through to the balanced + branch (it has no electrical meaning). + + This is private: it is an implementation detail shared by the + strategies and `build_assignment`, not part of the package API. + + Args: + grid: the grid to inspect. Not mutated. + candidate_names: names the caller intends to assign itself; + excluded from the seed so they are not double-counted. + usage_factor: nameplate fraction, applied to every load's + `power_watts` before it is added to a leg. + + Returns: + A fresh 3-element list `[L1, L2, L3]` of effective watts. + Always a new list — callers mutate it freely. + """ + legs = [0.0, 0.0, 0.0] + for name, node in grid.nodes.items(): + if node.is_generator or node.power_watts <= 0 or name in candidate_names: + continue + effective_w = node.power_watts * usage_factor + phase = node.phase + if isinstance(phase, int) and 1 <= phase <= 3: + legs[phase - 1] += effective_w + elif isinstance(phase, list) and phase: + legs_used = [p for p in phase if isinstance(p, int) and 1 <= p <= 3] + for leg in legs_used: + legs[leg - 1] += effective_w / len(legs_used) + else: + # balanced / unphased / string marker — spread evenly + if isinstance(phase, str): + logger.warning( + "Node %r carries legacy string phase marker %r; treating " + "as unphased (balanced across L1/L2/L3) in the leg seed.", + name, + phase, + ) + for i in range(3): + legs[i] += effective_w / 3 + return legs + + +def build_assignment( + grid: PowerGrid, phases: dict[str, int], usage_factor: float +) -> PhaseAssignment: + """Wrap a raw `{name: leg}` mapping into a scored `PhaseAssignment`. + + The single place leg totals and the balance metric are computed, + so every strategy reports them identically. A strategy's only job + is to decide `phases`; this function does the bookkeeping. + + The leg totals are the sum of two parts: + + - the **fixed seed** — every load *not* in `phases`, via + `_fixed_leg_seed` (balanced loads spread, pre-assigned loads + on their legs); + - the **candidate placements** — each `name -> leg` in `phases`, + adding that load's effective power to the chosen leg. + + `balance_pct` is then `100 * std / mean` over the three totals, + matching `io.print_grid_info`. A grid with no load anywhere has + `mean == 0`; balance is defined as 0.0 there rather than dividing + by zero. + + Args: + grid: the grid the mapping was computed for. Not mutated. + Names in `phases` must exist in `grid.nodes`. + phases: the strategy's decision — `name -> leg (1/2/3)`. May + be empty (e.g. a grid whose every load is already phased + or too big to single-phase); the result is then just the + seed. + usage_factor: nameplate fraction; must match what the + strategy used to pick `phases`, and is stored verbatim on + the result. + + Returns: + A fully populated, immutable `PhaseAssignment`. + + Raises: + KeyError: if `phases` names a node not present in `grid`. + """ + legs = _fixed_leg_seed(grid, set(phases), usage_factor) + for name, leg in phases.items(): + legs[leg - 1] += grid.nodes[name].power_watts * usage_factor + + mean = sum(legs) / 3 + if mean > 0: + variance = sum((leg - mean) ** 2 for leg in legs) / 3 + balance_pct = 100.0 * variance**0.5 / mean + else: + balance_pct = 0.0 + + return PhaseAssignment( + phases=phases, + usage_factor=usage_factor, + leg_totals_w=(legs[0], legs[1], legs[2]), + balance_pct=balance_pct, + ) + + +def apply_assignment(grid: PowerGrid, assignment: PhaseAssignment) -> None: + """Write an assignment back onto the grid, mutating `PowerNode.phase`. + + The one place a strategy's plan actually touches the grid. Kept + separate from the strategies so that planning is free of side + effects: you can compute, compare, and discard `PhaseAssignment`s + without ever changing the grid, then commit exactly one. + + Only the candidate loads in `assignment.phases` are touched — + each has its `phase` set to the assigned int leg. Multi-phase + loads, pre-assigned loads, the generator, and zero-power nodes + are never in `phases` and so are left exactly as they were. + + Idempotent: applying the same assignment twice is a no-op the + second time. Applying it does *not* recompute `power_per_phase` + — that happens when the grid is next loaded or analysed. + + Args: + grid: the grid to mutate. Must contain every node named in + `assignment.phases`. + assignment: the plan to commit, typically straight from + `assign_round_robin` or `assign_greedy`. + + Raises: + KeyError: if the assignment names a node not in `grid` + (e.g. the assignment was computed against a different + grid). + """ + for name, leg in assignment.phases.items(): + grid.nodes[name].phase = leg diff --git a/src/nopywer/pp_interop/phases/_export.py b/src/nopywer/pp_interop/phases/_export.py new file mode 100644 index 0000000..5b3b097 --- /dev/null +++ b/src/nopywer/pp_interop/phases/_export.py @@ -0,0 +1,82 @@ +"""One-call helper to phase a GeoJSON fixture and write the result. + +The building blocks (`assign_greedy` / `assign_round_robin`, +`apply_assignment`, `PowerGrid.to_geojson`) compose into a single +common workflow: load a fixture, plan phases, commit them, write +the phased fixture back out. `phase_geojson` is that workflow as a +single function, intended for scripts and the CLI rather than for +internal callers (which should keep using the individual primitives +so the call site shows every step). +""" + +import json +from pathlib import Path +from typing import Literal + +from ...io import load_geojson +from ...models import PowerGrid +from ._common import PhaseAssignment, apply_assignment +from ._greedy import assign_greedy +from ._round_robin import assign_round_robin + +Strategy = Literal["greedy", "round_robin"] + +_STRATEGIES = { + "greedy": assign_greedy, + "round_robin": assign_round_robin, +} + + +def phase_geojson( + input_path: Path | str, + output_path: Path | str, + *, + usage_factor: float, + strategy: Strategy = "greedy", +) -> PhaseAssignment: + """Load a GeoJSON, plan and apply phases, write the result out. + + The one-call convenience wrapper around the full + `load_geojson → assign_* → apply_assignment → to_geojson → write` + pipeline. Input and output paths are kept distinct so the source + fixture is never modified in place. + + Args: + input_path: GeoJSON file to read. May be in either the + hand-authored input schema or the round-tripped export + schema — `load_geojson` handles both. + output_path: GeoJSON file to write. Will be overwritten if it + exists. Pretty-printed with `indent=2` for diff-friendly + commits to fixtures. + usage_factor: **required**, keyword-only. Assumed fraction of + nameplate power loads draw together. No default — see + `config.DEFAULT_USAGE_FACTOR` (0.5) for the project-wide + reference figure callers can adopt explicitly. + strategy: which phase-assignment strategy to use. `"greedy"` + (size-aware, doc 10's Strategy D, recommended default) + or `"round_robin"` (positional, Strategy B, useful as a + baseline). New strategies should land here as new string + keys; the literal type is deliberately closed. + + Returns: + The `PhaseAssignment` that was applied. Useful for logging + the `balance_pct` or diffing two strategy runs without + having to reload the written file. + + Raises: + ValueError: if `strategy` is not one of the known names. + FileNotFoundError: if `input_path` does not exist. + """ + if strategy not in _STRATEGIES: + raise ValueError(f"unknown strategy {strategy!r}; expected one of {sorted(_STRATEGIES)}") + + nodes, cables = load_geojson(input_path) + grid = PowerGrid(nodes=nodes, cables=cables) + + plan = _STRATEGIES[strategy](grid, usage_factor=usage_factor) + apply_assignment(grid, plan) + + with open(output_path, "w") as f: + json.dump(grid.to_geojson(), f, indent=2) + + return plan diff --git a/src/nopywer/pp_interop/phases/_greedy.py b/src/nopywer/pp_interop/phases/_greedy.py new file mode 100644 index 0000000..8fa7036 --- /dev/null +++ b/src/nopywer/pp_interop/phases/_greedy.py @@ -0,0 +1,93 @@ +"""Strategy D — greedy least-loaded-leg phase distribution. + +The size-aware counterpart to round-robin. Where Strategy B assigns +legs by *position* in the load list, this assigns by *load size*: + + 1. seed the three legs with the loads that are not being + distributed — balanced loads spread evenly, pre-assigned loads + on their declared legs (this is `_fixed_leg_seed`); + 2. sort the single-phase candidates heaviest-first; + 3. drop each candidate onto whichever leg is currently lightest. + +Heaviest-first matters: placing the big loads while the legs are +still near-empty leaves the small loads to fine-tune the balance at +the end. Do it the other way round and the last (largest) load can +blow a leg that the small loads had carefully levelled. This is the +standard greedy multiway-partition heuristic (the "longest processing +time" rule) — not provably optimal, but in practice it lands far +closer to a level three-way split than round-robin, especially when +load sizes vary a lot. + +== Cost of the win == + +Greedy needs trustworthy load sizes — that is the input it exploits. +If the nameplate figures are guesses, its apparent precision is +false and round-robin's positional honesty may be preferable. It is +also less obvious on site: the leg a load ends up on depends on +every heavier load before it, not on a rule you can read off a list. + +For the simpler positional strategy see `_round_robin.py` (Strategy B). +""" + +from ...models import PowerGrid +from ._common import ( + PhaseAssignment, + _fixed_leg_seed, + build_assignment, + single_phase_candidates, +) + + +def assign_greedy(grid: PowerGrid, *, usage_factor: float) -> PhaseAssignment: + """Assign each single-phase candidate to the currently-lightest leg. + + Candidates (from `single_phase_candidates`) are sorted by + effective power, heaviest first, and placed one at a time onto + whichever leg has the least load *so far* — where "so far" + starts from the fixed seed of non-distributed loads, not from + zero. So greedy balances *around* the balanced and pre-assigned + loads already on the grid, rather than pretending the grid is + empty. + + Tie-breaking is deterministic: when two or more legs are equally + loaded, `min` picks the lowest index (L1 before L2 before L3). + Combined with the heaviest-first sort, this makes the whole + result reproducible for a given grid and usage factor. + + This is a heuristic, not an optimiser. It will not always find + the theoretically most level split (that is NP-hard in general), + but for festival-shaped inputs — a handful of medium loads and a + long tail of small ones — it gets very close, and unlike + round-robin it is insensitive to the order loads appear in the + grid. + + Args: + grid: the grid to plan phases for. Not mutated — pass the + result to `apply_assignment` to write it back. + usage_factor: **required**, keyword-only. Assumed fraction + of nameplate power loads draw together. No default — + see `config.DEFAULT_USAGE_FACTOR` (0.5) for the + project-wide reference figure. Unlike round-robin, this + **does** affect the assignment: both the lightest-leg + decision and the heaviest-first sort run on usage- + adjusted watts, and the factor also shifts the candidate + capacity cut-off. A different factor can therefore + produce a genuinely different leg layout, not just + rescaled totals. + + Returns: + A `PhaseAssignment` whose `balance_pct` is typically far + lower than round-robin's on the same grid. If the grid has + no single-phase candidates, `phases` is empty and the result + describes just the fixed (balanced / pre-assigned) loads. + """ + candidates = single_phase_candidates(grid, usage_factor=usage_factor) + legs = _fixed_leg_seed(grid, {name for name, _ in candidates}, usage_factor) + + phases: dict[str, int] = {} + for name, effective_w in sorted(candidates, key=lambda c: c[1], reverse=True): + target = min(range(3), key=lambda i: legs[i]) + legs[target] += effective_w + phases[name] = target + 1 + + return build_assignment(grid, phases, usage_factor) diff --git a/src/nopywer/pp_interop/phases/_round_robin.py b/src/nopywer/pp_interop/phases/_round_robin.py new file mode 100644 index 0000000..28ab015 --- /dev/null +++ b/src/nopywer/pp_interop/phases/_round_robin.py @@ -0,0 +1,74 @@ +"""Strategy B — round-robin phase distribution. + +The simpler of the two strategies: walk the candidate loads in grid +order and assign legs cyclically, `L1, L2, L3, L1, L2, L3, ...`. + +This mirrors what a careful electrician does pulling a corridor of +stalls — alternate the leg on each successive drop. It is purely +*positional*: it never looks at how much power a load draws, so a +run of heavy loads landing on consecutive positions can still pile +onto one leg. The upside is that it is trivial, deterministic, and a +reasonable default when load sizes are unknown or roughly uniform. + +== When this is the right choice == + + - load sizes are unknown, unreliable, or genuinely uniform — there + is nothing for a size-aware strategy to exploit; + - you want the assignment to be obvious and auditable on site (the + leg follows position in the list, full stop); + - it is a baseline: doc 10 uses round-robin as the reference that + greedy has to beat. + +For size-aware balancing see `_greedy.py` (Strategy D), which on +fixtures with varied load sizes typically cuts the imbalance by an +order of magnitude. +""" + +from ...models import PowerGrid +from ._common import ( + PhaseAssignment, + build_assignment, + single_phase_candidates, +) + + +def assign_round_robin(grid: PowerGrid, *, usage_factor: float) -> PhaseAssignment: + """Assign each single-phase candidate a leg, cycling L1 / L2 / L3. + + The i-th candidate (in grid order) gets leg `(i % 3) + 1`, so the + sequence is `L1, L2, L3, L1, ...`. Candidate selection is done by + `single_phase_candidates` — generators, zero-power nodes, loads + already carrying a `phase`, and loads too big for one leg are all + excluded and left as-is. + + The assignment is **positional, not size-aware**. Three 4 kW + loads followed by three 1 kW loads would put all the heavy ones + on L1/L2/L3 and all the light ones on L1/L2/L3 again — balanced + here only because the counts line up. Reorder the grid and the + balance changes. That sensitivity is the price of simplicity; it + is exactly what `assign_greedy` removes. + + Args: + grid: the grid to plan phases for. Not mutated — pass the + result to `apply_assignment` to write it back. + usage_factor: **required**, keyword-only. Assumed fraction + of nameplate power loads draw together. No default — + see `config.DEFAULT_USAGE_FACTOR` (0.5) for the + project-wide reference figure. Does **not** change + *which* leg a + load gets (this strategy is positional), but it does + scale the reported `leg_totals_w` and `balance_pct`, and + it shifts the capacity cut-off in + `single_phase_candidates` (so a load can move in or out + of the candidate set as the factor changes). + + Returns: + A `PhaseAssignment`. Fully reproducible for a given grid and + usage factor: same grid order in, same legs out. If the grid + has no single-phase candidates, `phases` is empty and the + result describes just the fixed (balanced / pre-assigned) + loads. + """ + candidates = single_phase_candidates(grid, usage_factor=usage_factor) + phases = {name: (i % 3) + 1 for i, (name, _) in enumerate(candidates)} + return build_assignment(grid, phases, usage_factor) diff --git a/tests/fixtures/2026-05-14_martin.geojson b/tests/fixtures/2026-05-14_martin.geojson new file mode 100644 index 0000000..c817621 --- /dev/null +++ b/tests/fixtures/2026-05-14_martin.geojson @@ -0,0 +1,1417 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13953071992767785, + 41.6999752927288 + ], + [ + -0.13989663973096164, + 41.69911453243565 + ] + ] + }, + "properties": { + "id": "cable_0", + "nodes": [ + "distro9", + "distro8" + ], + "from": "distro9", + "to": "distro8", + "length_m": 110.4, + "area_mm2": 6.0, + "plugs_and_sockets_a": 32.0, + "cable_type": "1P 32A \u2014 6.0mm\u00b2", + "current_a": 30.6, + "cum_power_kw": 6.33, + "vdrop_volts": 21.66 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.14053148336696134, + 41.701600130185604 + ], + [ + -0.13988015097303447, + 41.701130634599245 + ] + ] + }, + "properties": { + "id": "cable_1", + "nodes": [ + "zaz", + "generator" + ], + "from": "zaz", + "to": "generator", + "length_m": 85.2, + "area_mm2": 6.0, + "plugs_and_sockets_a": 32.0, + "cable_type": "1P 32A \u2014 6.0mm\u00b2", + "current_a": 81.2, + "cum_power_kw": 16.8, + "vdrop_volts": 44.33 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.139532242707377, + 41.69998389335092 + ], + [ + -0.14007635726339934, + 41.69963441734 + ] + ] + }, + "properties": { + "id": "cable_2", + "nodes": [ + "distro9", + "curious creatures" + ], + "from": "distro9", + "to": "curious creatures", + "length_m": 69.7, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 24.2, + "cum_power_kw": 5.0, + "vdrop_volts": 25.9 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.14072105157337192, + 41.702002684355925 + ], + [ + -0.14052755675000794, + 41.70162945801931 + ] + ] + }, + "properties": { + "id": "cable_3", + "nodes": [ + "garden of joy", + "zaz" + ], + "from": "garden of joy", + "to": "zaz", + "length_m": 54.5, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 48.3, + "cum_power_kw": 10.0, + "vdrop_volts": 40.51 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13989743376584954, + 41.69911326307554 + ], + [ + -0.1394391591861685, + 41.6989932968759 + ] + ] + }, + "properties": { + "id": "cable_4", + "nodes": [ + "distro8", + "planet pulpo" + ], + "from": "distro8", + "to": "planet pulpo", + "length_m": 50.4, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 9.7, + "cum_power_kw": 2.0, + "vdrop_volts": 7.49 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13989662913899972, + 41.69911360709415 + ], + [ + -0.14028018307834664, + 41.698835653220975 + ] + ] + }, + "properties": { + "id": "cable_5", + "nodes": [ + "distro8", + "wonderever" + ], + "from": "distro8", + "to": "wonderever", + "length_m": 54.4, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 6.8, + "cum_power_kw": 1.4, + "vdrop_volts": 5.66 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13983961469861161, + 41.69878117723728 + ], + [ + -0.1398980040638073, + 41.69911389878753 + ] + ] + }, + "properties": { + "id": "cable_6", + "nodes": [ + "oasis playground", + "distro8" + ], + "from": "oasis playground", + "to": "distro8", + "length_m": 47.3, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 14.5, + "cum_power_kw": 3.0, + "vdrop_volts": 10.54 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13989674641519334, + 41.69911370103021 + ], + [ + -0.1403477956168545, + 41.69906542845324 + ] + ] + }, + "properties": { + "id": "cable_7", + "nodes": [ + "distro8", + "mirage" + ], + "from": "distro8", + "to": "mirage", + "length_m": 47.9, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 6.8, + "cum_power_kw": 1.4, + "vdrop_volts": 4.98 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13989090923520467, + 41.70058300170854 + ], + [ + -0.1403111131585792, + 41.70021466955112 + ] + ] + }, + "properties": { + "id": "cable_8", + "nodes": [ + "distro1", + "eyao" + ], + "from": "distro1", + "to": "eyao", + "length_m": 63.8, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 9.7, + "cum_power_kw": 2.0, + "vdrop_volts": 9.48 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13990617156460367, + 41.69912572222792 + ], + [ + -0.14031724994819922, + 41.69938775779702 + ] + ] + }, + "properties": { + "id": "cable_9", + "nodes": [ + "distro8", + "pdr of fiascostan" + ], + "from": "distro8", + "to": "pdr of fiascostan", + "length_m": 54.9, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 9.7, + "cum_power_kw": 2.0, + "vdrop_volts": 8.16 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13989129991538604, + 41.7005836394078 + ], + [ + -0.14032125920402552, + 41.70076539629761 + ] + ] + }, + "properties": { + "id": "cable_10", + "nodes": [ + "distro1", + "glowy owl" + ], + "from": "distro1", + "to": "glowy owl", + "length_m": 51.1, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 9.7, + "cum_power_kw": 2.0, + "vdrop_volts": 7.59 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1398907790084798, + 41.70058278914213 + ], + [ + -0.1404629446182906, + 41.70047922303174 + ] + ] + }, + "properties": { + "id": "cable_11", + "nodes": [ + "distro1", + "mini camp" + ], + "from": "distro1", + "to": "mini camp", + "length_m": 59.0, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 4.8, + "cum_power_kw": 1.0, + "vdrop_volts": 4.38 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1398839389103155, + 41.70113013731505 + ], + [ + -0.14061737924151105, + 41.70110305493381 + ] + ] + }, + "properties": { + "id": "cable_12", + "nodes": [ + "generator", + "barrio espace" + ], + "from": "generator", + "to": "barrio espace", + "length_m": 71.1, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 14.5, + "cum_power_kw": 3.0, + "vdrop_volts": 15.85 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.139874356320217, + 41.70012865495601 + ], + [ + -0.13951696030729716, + 41.70022112725018 + ] + ] + }, + "properties": { + "id": "cable_13", + "nodes": [ + "glitch", + "distro2" + ], + "from": "glitch", + "to": "distro2", + "length_m": 41.5, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 19.3, + "cum_power_kw": 4.0, + "vdrop_volts": 12.34 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1405228605103245, + 41.70162137103771 + ], + [ + -0.14105370474606616, + 41.70178357945931 + ] + ] + }, + "properties": { + "id": "cable_14", + "nodes": [ + "zaz", + "ran'dome" + ], + "from": "zaz", + "to": "ran'dome", + "length_m": 57.7, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 19.3, + "cum_power_kw": 4.0, + "vdrop_volts": 17.15 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1405247969713634, + 41.7016202411872 + ], + [ + -0.14099377105678132, + 41.701384425977956 + ] + ] + }, + "properties": { + "id": "cable_15", + "nodes": [ + "zaz", + "desert dessert" + ], + "from": "zaz", + "to": "desert dessert", + "length_m": 57.0, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 32.8, + "cum_power_kw": 6.8, + "vdrop_volts": 28.81 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13964289449621192, + 41.70127410398372 + ], + [ + -0.13945297743107815, + 41.70160010569251 + ] + ] + }, + "properties": { + "id": "cable_16", + "nodes": [ + "rdtfgh", + "barrio del sol" + ], + "from": "rdtfgh", + "to": "barrio del sol", + "length_m": 49.5, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 17.8, + "cum_power_kw": 3.68, + "vdrop_volts": 13.54 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.14001492449097064, + 41.69989479508128 + ], + [ + -0.13951296635310073, + 41.70022530910071 + ] + ] + }, + "properties": { + "id": "cable_17", + "nodes": [ + "belly town", + "distro2" + ], + "from": "belly town", + "to": "distro2", + "length_m": 65.6, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 14.5, + "cum_power_kw": 3.0, + "vdrop_volts": 14.62 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13989755914393132, + 41.69911388770567 + ], + [ + -0.13914562055063537, + 41.69927447838743 + ] + ] + }, + "properties": { + "id": "cable_18", + "nodes": [ + "distro8", + "olive odissey" + ], + "from": "distro8", + "to": "olive odissey", + "length_m": 75.1, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 9.7, + "cum_power_kw": 2.0, + "vdrop_volts": 11.16 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13990704758599418, + 41.69912641093495 + ], + [ + -0.14039843147426054, + 41.69928042564868 + ] + ] + }, + "properties": { + "id": "cable_19", + "nodes": [ + "distro8", + "jamhouse" + ], + "from": "distro8", + "to": "jamhouse", + "length_m": 54.3, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 14.5, + "cum_power_kw": 3.0, + "vdrop_volts": 12.1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13990606235151692, + 41.69912544646274 + ], + [ + -0.13989660696530037, + 41.69911410712428 + ] + ] + }, + "properties": { + "id": "cable_20", + "nodes": [ + "distro8", + "distro8" + ], + "from": "distro8", + "to": "distro8", + "length_m": 11.5, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 0.0, + "cum_power_kw": 0.0, + "vdrop_volts": 0.0 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13951464719975476, + 41.70023034587373 + ], + [ + -0.13989025810158784, + 41.70058193887645 + ] + ] + }, + "properties": { + "id": "cable_21", + "nodes": [ + "distro2", + "distro1" + ], + "from": "distro2", + "to": "distro1", + "length_m": 60.0, + "area_mm2": 16.0, + "plugs_and_sockets_a": 63.0, + "cable_type": "1P 63A \u2014 16.0mm\u00b2", + "current_a": 63.1, + "cum_power_kw": 13.07, + "vdrop_volts": 9.1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1395212435711459, + 41.69999574982771 + ], + [ + -0.1395055790930972, + 41.700220130174756 + ] + ] + }, + "properties": { + "id": "cable_22", + "nodes": [ + "distro9", + "distro2" + ], + "from": "distro9", + "to": "distro2", + "length_m": 35.0, + "area_mm2": 16.0, + "plugs_and_sockets_a": 63.0, + "cable_type": "1P 63A \u2014 16.0mm\u00b2", + "current_a": 56.4, + "cum_power_kw": 11.67, + "vdrop_volts": 4.74 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1398907032137395, + 41.700583973416585 + ], + [ + -0.13987627447357368, + 41.70111958767342 + ] + ] + }, + "properties": { + "id": "cable_23", + "nodes": [ + "distro1", + "generator" + ], + "from": "distro1", + "to": "generator", + "length_m": 69.5, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 77.6, + "cum_power_kw": 16.07, + "vdrop_volts": 82.99 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13987287159693085, + 41.70112776842358 + ], + [ + -0.1396476485857442, + 41.70127259284582 + ] + ] + }, + "properties": { + "id": "cable_24", + "nodes": [ + "generator", + "rdtfgh" + ], + "from": "generator", + "to": "rdtfgh", + "length_m": 34.7, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 17.8, + "cum_power_kw": 3.68, + "vdrop_volts": 9.49 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14046027066882047, + 41.70048109809566 + ] + }, + "properties": { + "name": "mini camp", + "type": "load", + "power": 1000.0, + "cum_power": 1000.0, + "voltage": 142.6, + "vdrop_percent": 38.0, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1403126048615206, + 41.70021165965277 + ] + }, + "properties": { + "name": "eyao", + "type": "load", + "power": 2000.0, + "cum_power": 2000.0, + "voltage": 137.5, + "vdrop_percent": 40.22, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13986458645846486, + 41.70012838821719 + ] + }, + "properties": { + "name": "glitch", + "type": "load", + "power": 4000.0, + "cum_power": 4000.0, + "voltage": 125.6, + "vdrop_percent": 45.39, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14001451226101178, + 41.69988172981668 + ] + }, + "properties": { + "name": "belly town", + "type": "load", + "power": 3000.0, + "cum_power": 3000.0, + "voltage": 123.3, + "vdrop_percent": 46.39, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14032067533151324, + 41.699387585580126 + ] + }, + "properties": { + "name": "pdr of fiascostan", + "type": "load", + "power": 2000.0, + "cum_power": 2000.0, + "voltage": 103.3, + "vdrop_percent": 55.09, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14037095139108816, + 41.69906229826065 + ] + }, + "properties": { + "name": "mirage", + "type": "load", + "power": 1400.0, + "cum_power": 1400.0, + "voltage": 106.5, + "vdrop_percent": 53.7, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1402769285712588, + 41.69884015144679 + ] + }, + "properties": { + "name": "wonderever", + "type": "load", + "power": 1400.0, + "cum_power": 1400.0, + "voltage": 105.8, + "vdrop_percent": 54.0, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13983096674250983, + 41.698769513641636 + ] + }, + "properties": { + "name": "oasis playground", + "type": "load", + "power": 3000.0, + "cum_power": 3000.0, + "voltage": 101.0, + "vdrop_percent": 56.09, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1394393620604287, + 41.69898872265349 + ] + }, + "properties": { + "name": "planet pulpo", + "type": "load", + "power": 2000.0, + "cum_power": 2000.0, + "voltage": 104.0, + "vdrop_percent": 54.78, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1400824723373084, + 41.699631722152795 + ] + }, + "properties": { + "name": "curious creatures", + "type": "load", + "power": 10000.0, + "cum_power": 10000.0, + "voltage": 107.3, + "vdrop_percent": 53.35, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1403046266028607, + 41.700765329482905 + ] + }, + "properties": { + "name": "glowy owl", + "type": "load", + "power": 2000.0, + "cum_power": 2000.0, + "voltage": 139.4, + "vdrop_percent": 39.39, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14061462479929102, + 41.701104168536816 + ] + }, + "properties": { + "name": "barrio espace", + "type": "load", + "power": 3000.0, + "cum_power": 3000.0, + "voltage": 214.2, + "vdrop_percent": 6.87, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14105640391758656, + 41.70179263095132 + ] + }, + "properties": { + "name": "ran'dome", + "type": "load", + "power": 4000.0, + "cum_power": 4000.0, + "voltage": 168.5, + "vdrop_percent": 26.74, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14100409056756252, + 41.70136886602274 + ] + }, + "properties": { + "name": "desert dessert", + "type": "load", + "power": 6800.0, + "cum_power": 6800.0, + "voltage": 156.9, + "vdrop_percent": 31.78, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1407273698750896, + 41.701997572463505 + ] + }, + "properties": { + "name": "garden of joy", + "type": "load", + "power": 30000.0, + "cum_power": 30000.0, + "voltage": 145.2, + "vdrop_percent": 36.87, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1394518880437417, + 41.701599503491636 + ] + }, + "properties": { + "name": "barrio del sol", + "type": "load", + "power": 3680.0, + "cum_power": 3680.0, + "voltage": 207.0, + "vdrop_percent": 10.0, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1391394682682699, + 41.69927405035179 + ] + }, + "properties": { + "name": "olive odissey", + "type": "load", + "power": 2000.0, + "cum_power": 2000.0, + "voltage": 100.3, + "vdrop_percent": 56.39, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13952647308927593, + 41.69998517673939 + ] + }, + "properties": { + "name": "distro9", + "type": "load", + "power": 1000.0, + "cum_power": 26800.0, + "voltage": 133.2, + "vdrop_percent": 42.09, + "distro": { + "in": "3P 63.0A", + "out": { + "3P 32.0A": 1, + "1P 16.0A": 1 + } + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13987842843560366, + 41.701128430139995 + ] + }, + "properties": { + "name": "generator", + "type": "generator", + "power": 0.0, + "cum_power": 88280.0, + "voltage": 230, + "vdrop_percent": 0.0, + "distro": { + "in": "3P 125A", + "out": { + "3P 32.0A": 1, + "1P 16.0A": 3 + } + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1398906258410024, + 41.70058336835788 + ] + }, + "properties": { + "name": "distro1", + "type": "load", + "power": 0.0, + "cum_power": 38800.0, + "voltage": 147.0, + "vdrop_percent": 36.09, + "distro": { + "in": "1P 16.0A", + "out": { + "1P 16.0A": 3, + "3P 63.0A": 1 + } + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14052384215401315, + 41.70162166285516 + ] + }, + "properties": { + "name": "zaz", + "type": "load", + "power": 1000.0, + "cum_power": 41800.0, + "voltage": 185.7, + "vdrop_percent": 19.26, + "distro": { + "in": "3P 32.0A", + "out": { + "1P 16.0A": 3 + } + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13964561238814718, + 41.70127284174622 + ] + }, + "properties": { + "name": "rdtfgh", + "type": "load", + "power": 1000.0, + "cum_power": 4680.0, + "voltage": 220.5, + "vdrop_percent": 4.13, + "distro": { + "in": "1P 16.0A", + "out": { + "1P 16.0A": 1 + } + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13989622438430727, + 41.69911385446002 + ] + }, + "properties": { + "name": "distro8", + "type": "load", + "power": 1000.0, + "cum_power": 15800.0, + "voltage": 111.5, + "vdrop_percent": 51.52, + "distro": { + "in": "3P 32.0A", + "out": { + "1P 16.0A": 7 + } + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13951008153297134, + 41.70022595079415 + ] + }, + "properties": { + "name": "distro2", + "type": "load", + "power": 0.0, + "cum_power": 33800.0, + "voltage": 137.9, + "vdrop_percent": 40.04, + "distro": { + "in": "3P 63.0A", + "out": { + "1P 16.0A": 2, + "3P 63.0A": 1 + } + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1398945488962474, + 41.700573358856026 + ] + }, + "properties": { + "name": "distro", + "type": "load", + "power": 0.0, + "cum_power": 0.0, + "voltage": 0.0, + "vdrop_percent": 0.0, + "distro": { + "in": null, + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14040007075315172, + 41.69928240812505 + ] + }, + "properties": { + "name": "jamhouse", + "type": "load", + "power": 3000.0, + "cum_power": 3000.0, + "voltage": 99.4, + "vdrop_percent": 56.78, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1399063474789402, + 41.69912586313198 + ] + }, + "properties": { + "name": "distro6", + "type": "load", + "power": 0.0, + "cum_power": 0.0, + "voltage": 0.0, + "vdrop_percent": 0.0, + "distro": { + "in": null, + "out": {} + } + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/2026-05-14_martin_modified.geojson b/tests/fixtures/2026-05-14_martin_modified.geojson new file mode 100644 index 0000000..79346ac --- /dev/null +++ b/tests/fixtures/2026-05-14_martin_modified.geojson @@ -0,0 +1,1440 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13953071992767785, + 41.6999752927288 + ], + [ + -0.13989663973096164, + 41.69911453243565 + ] + ] + }, + "properties": { + "id": "cable_0", + "nodes": [ + "distro9", + "distro8" + ], + "from": "distro9", + "to": "distro8", + "length_m": 110.4, + "area_mm2": 6.0, + "plugs_and_sockets_a": 32.0, + "cable_type": "1P 32A \u2014 6.0mm\u00b2", + "current_a": 30.6, + "cum_power_kw": 6.33, + "vdrop_volts": 21.66 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.14053148336696134, + 41.701600130185604 + ], + [ + -0.13988015097303447, + 41.701130634599245 + ] + ] + }, + "properties": { + "id": "cable_1", + "nodes": [ + "zaz", + "generator" + ], + "from": "zaz", + "to": "generator", + "length_m": 85.2, + "area_mm2": 35.0, + "plugs_and_sockets_a": 125.0, + "cable_type": "3P 125A \u2014 35.0mm\u00b2", + "current_a": 81.2, + "cum_power_kw": 16.8, + "vdrop_volts": 44.33 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.139532242707377, + 41.69998389335092 + ], + [ + -0.14007635726339934, + 41.69963441734 + ] + ] + }, + "properties": { + "id": "cable_2", + "nodes": [ + "distro9", + "curious creatures" + ], + "from": "distro9", + "to": "curious creatures", + "length_m": 69.7, + "area_mm2": 6.0, + "plugs_and_sockets_a": 32.0, + "cable_type": "3P 32A \u2014 6.0mm\u00b2", + "current_a": 24.2, + "cum_power_kw": 5.0, + "vdrop_volts": 25.9 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.14072105157337192, + 41.702002684355925 + ], + [ + -0.14052755675000794, + 41.70162945801931 + ] + ] + }, + "properties": { + "id": "cable_3", + "nodes": [ + "garden of joy", + "zaz" + ], + "from": "garden of joy", + "to": "zaz", + "length_m": 54.5, + "area_mm2": 16.0, + "plugs_and_sockets_a": 63.0, + "cable_type": "3P 63A \u2014 16.0mm\u00b2", + "current_a": 48.3, + "cum_power_kw": 10.0, + "vdrop_volts": 40.51 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13989743376584954, + 41.69911326307554 + ], + [ + -0.1394391591861685, + 41.6989932968759 + ] + ] + }, + "properties": { + "id": "cable_4", + "nodes": [ + "distro8", + "planet pulpo" + ], + "from": "distro8", + "to": "planet pulpo", + "length_m": 50.4, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 9.7, + "cum_power_kw": 2.0, + "vdrop_volts": 7.49 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13989662913899972, + 41.69911360709415 + ], + [ + -0.14028018307834664, + 41.698835653220975 + ] + ] + }, + "properties": { + "id": "cable_5", + "nodes": [ + "distro8", + "wonderever" + ], + "from": "distro8", + "to": "wonderever", + "length_m": 54.4, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 6.8, + "cum_power_kw": 1.4, + "vdrop_volts": 5.66 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13983961469861161, + 41.69878117723728 + ], + [ + -0.1398980040638073, + 41.69911389878753 + ] + ] + }, + "properties": { + "id": "cable_6", + "nodes": [ + "oasis playground", + "distro8" + ], + "from": "oasis playground", + "to": "distro8", + "length_m": 47.3, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 14.5, + "cum_power_kw": 3.0, + "vdrop_volts": 10.54 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13989674641519334, + 41.69911370103021 + ], + [ + -0.1403477956168545, + 41.69906542845324 + ] + ] + }, + "properties": { + "id": "cable_7", + "nodes": [ + "distro8", + "mirage" + ], + "from": "distro8", + "to": "mirage", + "length_m": 47.9, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 6.8, + "cum_power_kw": 1.4, + "vdrop_volts": 4.98 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13989090923520467, + 41.70058300170854 + ], + [ + -0.1403111131585792, + 41.70021466955112 + ] + ] + }, + "properties": { + "id": "cable_8", + "nodes": [ + "distro1", + "eyao" + ], + "from": "distro1", + "to": "eyao", + "length_m": 63.8, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 9.7, + "cum_power_kw": 2.0, + "vdrop_volts": 9.48 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13990617156460367, + 41.69912572222792 + ], + [ + -0.14031724994819922, + 41.69938775779702 + ] + ] + }, + "properties": { + "id": "cable_9", + "nodes": [ + "distro8", + "pdr of fiascostan" + ], + "from": "distro8", + "to": "pdr of fiascostan", + "length_m": 54.9, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 9.7, + "cum_power_kw": 2.0, + "vdrop_volts": 8.16 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13989129991538604, + 41.7005836394078 + ], + [ + -0.14032125920402552, + 41.70076539629761 + ] + ] + }, + "properties": { + "id": "cable_10", + "nodes": [ + "distro1", + "glowy owl" + ], + "from": "distro1", + "to": "glowy owl", + "length_m": 51.1, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 9.7, + "cum_power_kw": 2.0, + "vdrop_volts": 7.59 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1398907790084798, + 41.70058278914213 + ], + [ + -0.1404629446182906, + 41.70047922303174 + ] + ] + }, + "properties": { + "id": "cable_11", + "nodes": [ + "distro1", + "mini camp" + ], + "from": "distro1", + "to": "mini camp", + "length_m": 59.0, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 4.8, + "cum_power_kw": 1.0, + "vdrop_volts": 4.38 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1398839389103155, + 41.70113013731505 + ], + [ + -0.14061737924151105, + 41.70110305493381 + ] + ] + }, + "properties": { + "id": "cable_12", + "nodes": [ + "generator", + "barrio espace" + ], + "from": "generator", + "to": "barrio espace", + "length_m": 71.1, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 14.5, + "cum_power_kw": 3.0, + "vdrop_volts": 15.85 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.139874356320217, + 41.70012865495601 + ], + [ + -0.13951696030729716, + 41.70022112725018 + ] + ] + }, + "properties": { + "id": "cable_13", + "nodes": [ + "glitch", + "distro2" + ], + "from": "glitch", + "to": "distro2", + "length_m": 41.5, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 19.3, + "cum_power_kw": 4.0, + "vdrop_volts": 12.34 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1405228605103245, + 41.70162137103771 + ], + [ + -0.14105370474606616, + 41.70178357945931 + ] + ] + }, + "properties": { + "id": "cable_14", + "nodes": [ + "zaz", + "ran'dome" + ], + "from": "zaz", + "to": "ran'dome", + "length_m": 57.7, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 19.3, + "cum_power_kw": 4.0, + "vdrop_volts": 17.15 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1405247969713634, + 41.7016202411872 + ], + [ + -0.14099377105678132, + 41.701384425977956 + ] + ] + }, + "properties": { + "id": "cable_15", + "nodes": [ + "zaz", + "desert dessert" + ], + "from": "zaz", + "to": "desert dessert", + "length_m": 57.0, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 32.8, + "cum_power_kw": 6.8, + "vdrop_volts": 28.81 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13964289449621192, + 41.70127410398372 + ], + [ + -0.13945297743107815, + 41.70160010569251 + ] + ] + }, + "properties": { + "id": "cable_16", + "nodes": [ + "rdtfgh", + "barrio del sol" + ], + "from": "rdtfgh", + "to": "barrio del sol", + "length_m": 49.5, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 17.8, + "cum_power_kw": 3.68, + "vdrop_volts": 13.54 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.14001492449097064, + 41.69989479508128 + ], + [ + -0.13951296635310073, + 41.70022530910071 + ] + ] + }, + "properties": { + "id": "cable_17", + "nodes": [ + "belly town", + "distro2" + ], + "from": "belly town", + "to": "distro2", + "length_m": 65.6, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 14.5, + "cum_power_kw": 3.0, + "vdrop_volts": 14.62 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13989755914393132, + 41.69911388770567 + ], + [ + -0.13914562055063537, + 41.69927447838743 + ] + ] + }, + "properties": { + "id": "cable_18", + "nodes": [ + "distro8", + "olive odissey" + ], + "from": "distro8", + "to": "olive odissey", + "length_m": 75.1, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 9.7, + "cum_power_kw": 2.0, + "vdrop_volts": 11.16 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13990704758599418, + 41.69912641093495 + ], + [ + -0.14039843147426054, + 41.69928042564868 + ] + ] + }, + "properties": { + "id": "cable_19", + "nodes": [ + "distro8", + "jamhouse" + ], + "from": "distro8", + "to": "jamhouse", + "length_m": 54.3, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 14.5, + "cum_power_kw": 3.0, + "vdrop_volts": 12.1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13990606235151692, + 41.69912544646274 + ], + [ + -0.13989660696530037, + 41.69911410712428 + ] + ] + }, + "properties": { + "id": "cable_20", + "nodes": [ + "distro8", + "distro8" + ], + "from": "distro8", + "to": "distro8", + "length_m": 11.5, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 0.0, + "cum_power_kw": 0.0, + "vdrop_volts": 0.0 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13951464719975476, + 41.70023034587373 + ], + [ + -0.13989025810158784, + 41.70058193887645 + ] + ] + }, + "properties": { + "id": "cable_21", + "nodes": [ + "distro2", + "distro1" + ], + "from": "distro2", + "to": "distro1", + "length_m": 60.0, + "area_mm2": 16.0, + "plugs_and_sockets_a": 63.0, + "cable_type": "1P 63A \u2014 16.0mm\u00b2", + "current_a": 63.1, + "cum_power_kw": 13.07, + "vdrop_volts": 9.1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1395212435711459, + 41.69999574982771 + ], + [ + -0.1395055790930972, + 41.700220130174756 + ] + ] + }, + "properties": { + "id": "cable_22", + "nodes": [ + "distro9", + "distro2" + ], + "from": "distro9", + "to": "distro2", + "length_m": 35.0, + "area_mm2": 16.0, + "plugs_and_sockets_a": 63.0, + "cable_type": "1P 63A \u2014 16.0mm\u00b2", + "current_a": 56.4, + "cum_power_kw": 11.67, + "vdrop_volts": 4.74 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.1398907032137395, + 41.700583973416585 + ], + [ + -0.13987627447357368, + 41.70111958767342 + ] + ] + }, + "properties": { + "id": "cable_23", + "nodes": [ + "distro1", + "generator" + ], + "from": "distro1", + "to": "generator", + "length_m": 69.5, + "area_mm2": 35.0, + "plugs_and_sockets_a": 125.0, + "cable_type": "3P 125A \u2014 35.0mm\u00b2", + "current_a": 77.6, + "cum_power_kw": 16.07, + "vdrop_volts": 82.99 + } + }, + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -0.13987287159693085, + 41.70112776842358 + ], + [ + -0.1396476485857442, + 41.70127259284582 + ] + ] + }, + "properties": { + "id": "cable_24", + "nodes": [ + "generator", + "rdtfgh" + ], + "from": "generator", + "to": "rdtfgh", + "length_m": 34.7, + "area_mm2": 2.5, + "plugs_and_sockets_a": 16.0, + "cable_type": "1P 16A \u2014 2.5mm\u00b2", + "current_a": 17.8, + "cum_power_kw": 3.68, + "vdrop_volts": 9.49 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14046027066882047, + 41.70048109809566 + ] + }, + "properties": { + "name": "mini camp", + "type": "load", + "power": 1000.0, + "cum_power": 1000.0, + "voltage": 142.6, + "vdrop_percent": 38.0, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1403126048615206, + 41.70021165965277 + ] + }, + "properties": { + "name": "eyao", + "type": "load", + "power": 2000.0, + "cum_power": 2000.0, + "voltage": 137.5, + "vdrop_percent": 40.22, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13986458645846486, + 41.70012838821719 + ] + }, + "properties": { + "name": "glitch", + "type": "load", + "power": 4000.0, + "cum_power": 4000.0, + "voltage": 125.6, + "vdrop_percent": 45.39, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14001451226101178, + 41.69988172981668 + ] + }, + "properties": { + "name": "belly town", + "type": "load", + "power": 3000.0, + "cum_power": 3000.0, + "voltage": 123.3, + "vdrop_percent": 46.39, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14032067533151324, + 41.699387585580126 + ] + }, + "properties": { + "name": "pdr of fiascostan", + "type": "load", + "power": 2000.0, + "cum_power": 2000.0, + "voltage": 103.3, + "vdrop_percent": 55.09, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14037095139108816, + 41.69906229826065 + ] + }, + "properties": { + "name": "mirage", + "type": "load", + "power": 1400.0, + "cum_power": 1400.0, + "voltage": 106.5, + "vdrop_percent": 53.7, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1402769285712588, + 41.69884015144679 + ] + }, + "properties": { + "name": "wonderever", + "type": "load", + "power": 1400.0, + "cum_power": 1400.0, + "voltage": 105.8, + "vdrop_percent": 54.0, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13983096674250983, + 41.698769513641636 + ] + }, + "properties": { + "name": "oasis playground", + "type": "load", + "power": 3000.0, + "cum_power": 3000.0, + "voltage": 101.0, + "vdrop_percent": 56.09, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1394393620604287, + 41.69898872265349 + ] + }, + "properties": { + "name": "planet pulpo", + "type": "load", + "power": 2000.0, + "cum_power": 2000.0, + "voltage": 104.0, + "vdrop_percent": 54.78, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1400824723373084, + 41.699631722152795 + ] + }, + "properties": { + "name": "curious creatures", + "type": "load", + "power": 10000.0, + "phase": [ + 1, + 2 + ], + "cum_power": 10000.0, + "voltage": 107.3, + "vdrop_percent": 53.35, + "distro": { + "in": "3P 32.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1403046266028607, + 41.700765329482905 + ] + }, + "properties": { + "name": "glowy owl", + "type": "load", + "power": 2000.0, + "cum_power": 2000.0, + "voltage": 139.4, + "vdrop_percent": 39.39, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14061462479929102, + 41.701104168536816 + ] + }, + "properties": { + "name": "barrio espace", + "type": "load", + "power": 3000.0, + "cum_power": 3000.0, + "voltage": 214.2, + "vdrop_percent": 6.87, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14105640391758656, + 41.70179263095132 + ] + }, + "properties": { + "name": "ran'dome", + "type": "load", + "power": 4000.0, + "cum_power": 4000.0, + "voltage": 168.5, + "vdrop_percent": 26.74, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14100409056756252, + 41.70136886602274 + ] + }, + "properties": { + "name": "desert dessert", + "type": "load", + "power": 6800.0, + "cum_power": 6800.0, + "voltage": 156.9, + "vdrop_percent": 31.78, + "distro": { + "in": "1P 16.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1407273698750896, + 41.701997572463505 + ] + }, + "properties": { + "name": "garden of joy", + "type": "load", + "power": 30000.0, + "cum_power": 30000.0, + "voltage": 145.2, + "vdrop_percent": 36.87, + "distro": { + "in": "3P 63.0A", + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1394518880437417, + 41.701599503491636 + ] + }, + "properties": { + "name": "barrio del sol", + "type": "load", + "power": 3680.0, + "cum_power": 3680.0, + "voltage": 207.0, + "vdrop_percent": 10.0, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1391394682682699, + 41.69927405035179 + ] + }, + "properties": { + "name": "olive odissey", + "type": "load", + "power": 2000.0, + "cum_power": 2000.0, + "voltage": 100.3, + "vdrop_percent": 56.39, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13952647308927593, + 41.69998517673939 + ] + }, + "properties": { + "name": "distro9", + "type": "load", + "power": 1000.0, + "cum_power": 26800.0, + "voltage": 133.2, + "vdrop_percent": 42.09, + "distro": { + "in": "3P 63.0A", + "out": { + "3P 32.0A": 2 + } + }, + "phase": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13987842843560366, + 41.701128430139995 + ] + }, + "properties": { + "name": "generator", + "type": "generator", + "power": 0.0, + "cum_power": 88280.0, + "voltage": 230, + "vdrop_percent": 0.0, + "distro": { + "in": "3P 125A", + "out": { + "3P 125.0A": 2, + "1P 16.0A": 2 + } + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1398906258410024, + 41.70058336835788 + ] + }, + "properties": { + "name": "distro1", + "type": "load", + "power": 0.0, + "cum_power": 38800.0, + "voltage": 147.0, + "vdrop_percent": 36.09, + "distro": { + "in": "3P 125.0A", + "out": { + "1P 16.0A": 3, + "3P 63.0A": 1 + } + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14052384215401315, + 41.70162166285516 + ] + }, + "properties": { + "name": "zaz", + "type": "load", + "power": 1000.0, + "cum_power": 41800.0, + "voltage": 185.7, + "vdrop_percent": 19.26, + "distro": { + "in": "3P 125.0A", + "out": { + "3P 63.0A": 1, + "1P 16.0A": 2 + } + }, + "phase": 3 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13964561238814718, + 41.70127284174622 + ] + }, + "properties": { + "name": "rdtfgh", + "type": "load", + "power": 1000.0, + "cum_power": 4680.0, + "voltage": 220.5, + "vdrop_percent": 4.13, + "distro": { + "in": "1P 16.0A", + "out": { + "1P 16.0A": 1 + } + }, + "phase": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13989622438430727, + 41.69911385446002 + ] + }, + "properties": { + "name": "distro8", + "type": "load", + "power": 1000.0, + "cum_power": 15800.0, + "voltage": 111.5, + "vdrop_percent": 51.52, + "distro": { + "in": "3P 32.0A", + "out": { + "1P 16.0A": 7 + } + }, + "phase": 2 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.13951008153297134, + 41.70022595079415 + ] + }, + "properties": { + "name": "distro2", + "type": "load", + "power": 0.0, + "cum_power": 33800.0, + "voltage": 137.9, + "vdrop_percent": 40.04, + "distro": { + "in": "3P 63.0A", + "out": { + "1P 16.0A": 2, + "3P 63.0A": 1 + } + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1398945488962474, + 41.700573358856026 + ] + }, + "properties": { + "name": "distro", + "type": "load", + "power": 0.0, + "cum_power": 0.0, + "voltage": 0.0, + "vdrop_percent": 0.0, + "distro": { + "in": null, + "out": {} + } + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.14040007075315172, + 41.69928240812505 + ] + }, + "properties": { + "name": "jamhouse", + "type": "load", + "power": 3000.0, + "cum_power": 3000.0, + "voltage": 99.4, + "vdrop_percent": 56.78, + "distro": { + "in": "1P 16.0A", + "out": {} + }, + "phase": 1 + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -0.1399063474789402, + 41.69912586313198 + ] + }, + "properties": { + "name": "distro6", + "type": "load", + "power": 0.0, + "cum_power": 0.0, + "voltage": 0.0, + "vdrop_percent": 0.0, + "distro": { + "in": null, + "out": {} + } + } + } + ] +} \ No newline at end of file diff --git a/tests/test_io_logging.py b/tests/test_io_logging.py new file mode 100644 index 0000000..c16abd6 --- /dev/null +++ b/tests/test_io_logging.py @@ -0,0 +1,124 @@ +"""Tests for the diagnostic logging emitted by `nopywer.io.load_geojson`. + +The loader is the project's front door for fixture data and silently +coerces a number of borderline inputs (unknown property keys, legacy +string phase markers). This module pins the warnings/diagnostics that +surface those coercions so they cannot regress to silent behaviour. +""" + +import logging + +from nopywer.io import load_geojson + + +def _point_feature(name: str, **extra_props) -> dict: + """A minimal valid Point feature with whatever extra properties given.""" + return { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0.0, 0.0]}, + "properties": {"name": name, "power": 1000.0, **extra_props}, + } + + +def _fc(*features: dict) -> dict: + return {"type": "FeatureCollection", "features": list(features)} + + +def test_legacy_string_phase_marker_logs_a_warning(caplog): + """A node whose `phase` is a string ("U"/"Y") triggers a WARNING. + + What: loading a Point feature with `phase: "U"` produces a + `logging.WARNING` naming the node and the marker, and the load + still succeeds with the load spread evenly across L1/L2/L3. + + Why it matters: `"U"` / `"Y"` are legacy sub-grid reporting + markers with no electrical meaning. They look phase-like in the + GeoJSON and slip through silently otherwise, which is misleading + when reading a `runpp_3ph` result. The warning is the operator's + only signal that the marker propagated as far as the loader. + """ + caplog.set_level(logging.WARNING, logger="nopywer.io") + nodes, _ = load_geojson(_fc(_point_feature("stage_left", phase="U"))) + + # behaviour: still loads, treated as unphased + assert nodes["stage_left"].phase == "U" + assert nodes["stage_left"].power_per_phase.tolist() == [1000.0 / 3] * 3 + + # diagnostic: a single warning naming the node and the marker + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "stage_left" in warnings[0].getMessage() + assert "'U'" in warnings[0].getMessage() + + +def test_int_phase_emits_no_warning(caplog): + """A well-formed integer phase does not trigger the legacy-marker warning. + + What: loading a Point feature with `phase: 2` produces no + `WARNING`-level log records. + + Why it matters: the warning would lose meaning if it fired for + normal data. This test pins it as legacy-marker-specific. + """ + caplog.set_level(logging.WARNING, logger="nopywer.io") + load_geojson(_fc(_point_feature("ok", phase=2))) + assert [r for r in caplog.records if r.levelno == logging.WARNING] == [] + + +def test_unknown_property_key_logs_a_debug_diagnostic(caplog): + """An unrecognised property key surfaces at DEBUG level. + + What: loading a Point feature with a property key not in the + known set (here, the typo `powr` for `power`) produces a + `logging.DEBUG` record naming the feature and the unknown key. + + Why it matters: `load_geojson` silently ignores unknown keys — + a typo for a known key (`powr` instead of `power`) would + otherwise load with the default value and never get flagged. + DEBUG (not WARNING) because legitimate exports also carry keys + we don't read; this is a "if you suspect a typo, turn on DEBUG" + aid, not a noisy default. + """ + caplog.set_level(logging.DEBUG, logger="nopywer.io") + load_geojson(_fc(_point_feature("typo_stage", powr=2000.0))) + + debug_records = [ + r for r in caplog.records if r.levelno == logging.DEBUG and "powr" in r.getMessage() + ] + assert len(debug_records) == 1 + assert "typo_stage" in debug_records[0].getMessage() + + +def test_recognised_export_keys_do_not_log(caplog): + """Computed fields from a round-tripped nopywer export are silent. + + What: loading a Point feature with the full set of computed + export keys (`voltage`, `vdrop_percent`, `cum_power_watts`, + `distro`, ...) produces no DEBUG-level records about unknown + keys. + + Why it matters: round-tripping an export back into the loader + is the canonical workflow (see doc 11). If the loader warned on + every computed field, the DEBUG channel would be useless noise + every time someone reloads their own previous output. + """ + caplog.set_level(logging.DEBUG, logger="nopywer.io") + load_geojson( + _fc( + _point_feature( + "exported", + voltage=228.5, + vdrop_percent=1.75, + cum_power_watts=3000.0, + distro={"in": "3P 32A", "out": {}}, + i_sc_ka=2.5, + type="load", + ) + ) + ) + unknown_debugs = [ + r + for r in caplog.records + if r.levelno == logging.DEBUG and "not used by load_geojson" in r.getMessage() + ] + assert unknown_debugs == [] diff --git a/tests/test_models.py b/tests/test_models.py index ce95c0d..72efe93 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -90,6 +90,31 @@ def test_power_node_to_geojson(): assert props["voltage"] == 228.5 assert props["vdrop_percent"] == 1.75 assert props["distro"] == {"in": "3P 32A", "out": {"1P 16A": 2}} + # phase is None on this node and is intentionally omitted from the + # export, not emitted as `"phase": null`. + assert "phase" not in props + + +def test_power_node_to_geojson_emits_phase_when_set(): + """Phase is emitted whenever it is non-None, in any form it can take. + + What: an int phase, a multi-phase list, and a legacy string marker + all appear in the exported properties; only `phase=None` is + omitted. + + Why it matters: this is the round-trip contract for + `pp_interop.phases`. A planner assigns `phase` on the grid via + `apply_assignment`; that choice has to survive a `to_geojson` + → `load_geojson` cycle, otherwise committing a plan to a fixture + silently loses it. + """ + int_phase = PowerNode(name="a", lon=0, lat=0, phase=2) + list_phase = PowerNode(name="b", lon=0, lat=0, phase=[1, 2]) + str_phase = PowerNode(name="c", lon=0, lat=0, phase="U") + + assert int_phase.to_geojson()["properties"]["phase"] == 2 + assert list_phase.to_geojson()["properties"]["phase"] == [1, 2] + assert str_phase.to_geojson()["properties"]["phase"] == "U" def test_generator_node_to_geojson(): diff --git a/tests/test_pp_interop_parity.py b/tests/test_pp_interop_parity.py index 0256dda..25c400d 100644 --- a/tests/test_pp_interop_parity.py +++ b/tests/test_pp_interop_parity.py @@ -41,7 +41,7 @@ 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. +— the 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). diff --git a/tests/test_pp_interop_phases.py b/tests/test_pp_interop_phases.py new file mode 100644 index 0000000..cf27900 --- /dev/null +++ b/tests/test_pp_interop_phases.py @@ -0,0 +1,680 @@ +"""Tests for the per-load phase distribution strategies. + +`nopywer.pp_interop.phases` synthesises a leg (L1/L2/L3) for every +single-phase load on a grid that shipped without phase data — the +prerequisite for asymmetric `runpp_3ph` work (research doc 10). + +Two strategies share one set of scaffolding: + + - `_common` — candidate selection, the fixed-leg seed, the balance + metric, and `apply_assignment`. + - `assign_round_robin` (Strategy B) — positional, cyclic legs. + - `assign_greedy` (Strategy D) — size-aware, heaviest load onto the + currently-lightest leg. + +The unit tests below build tiny hand-made grids with arithmetic that +works out cleanly, so an expected leg layout can be asserted exactly. +One integration test runs both strategies against the real 2026 +field-export fixture and checks the property that motivates having +two strategies at all: greedy balances better than round-robin. +""" + +from pathlib import Path + +import pytest + +from nopywer.models import PowerGrid, PowerNode +from nopywer.pp_interop.phases import ( + DEFAULT_USAGE_FACTOR, + SINGLE_PHASE_CAPACITY_W, + PhaseAssignment, + apply_assignment, + assign_greedy, + assign_round_robin, + phase_geojson, + single_phase_candidates, +) +from nopywer.pp_interop.phases._common import _fixed_leg_seed, build_assignment + +FIXTURES = Path(__file__).parent / "fixtures" + + +def make_grid(loads: list[tuple[str, float, object]], gen_power: float = 0.0) -> PowerGrid: + """Build a cable-less `PowerGrid` from `(name, power_watts, phase)` specs. + + The strategies never touch cables, so an empty cable dict is fine. + Each node is given a distinct longitude purely so they are + well-formed; geometry is irrelevant to phase distribution. + """ + nodes = {"gen": PowerNode("gen", 0.0, 0.0, power_watts=gen_power, is_generator=True)} + for i, (name, power, phase) in enumerate(loads): + nodes[name] = PowerNode(name, float(i + 1), 0.0, power_watts=power, phase=phase) + return PowerGrid(nodes=nodes, cables={}) + + +# --- single_phase_candidates ------------------------------------------------- + + +def test_single_phase_capacity_constant(): + """The single-phase capacity cut-off is derived, not a magic number. + + What: `SINGLE_PHASE_CAPACITY_W` equals 16 A x 230 V x 0.9. + + Why it matters: this constant decides which loads are eligible to + sit on one leg. It must be *computed* from the catalogue's 16 A + rating and the global voltage / power-factor constants — not + hard-coded as ~3.3 kW — so that if any of those inputs change the + cut-off tracks them instead of silently going stale. + """ + assert SINGLE_PHASE_CAPACITY_W == pytest.approx(16 * 230 * 0.9) + + +def test_candidates_include_only_eligible_loads(): + """`single_phase_candidates` applies all four exclusion rules. + + What: a grid with one of every disqualifying case plus one valid + load returns only the valid load. + + Why it matters: each excluded category is a distinct bug class if + it leaked through — a strategy that tried to phase the generator, + a 0 W transit distro, a load already carrying a `phase`, or a load + too big for a single 16 A leg would each produce a physically + wrong or destructive plan. This pins all four guards at once. + """ + grid = make_grid( + [ + ("small", 2000.0, None), # eligible + ("zero", 0.0, None), # excluded: no power + ("phased_int", 2000.0, 2), # excluded: already has a phase + ("phased_list", 2000.0, [1, 3]), # excluded: already multi-phase + ("marker", 2000.0, "U"), # excluded: sub-grid marker counts as phased + ("huge", 20000.0, None), # excluded: too big for one leg even at 0.5x + ], + gen_power=1000.0, + ) + names = [name for name, _ in single_phase_candidates(grid, usage_factor=0.5)] + assert names == ["small"] + + +def test_candidates_return_usage_adjusted_watts_in_grid_order(): + """Candidates carry usage-adjusted watts and stay in grid order. + + What: at usage 0.5x, two 2 kW / 1 kW loads come back as 1 kW / + 0.5 kW pairs, in the order they appear in the grid. + + Why it matters: greedy *sorts* on the returned watts, so they must + already be effective (usage-adjusted), not nameplate — otherwise + greedy would balance a peak that never happens. Round-robin + *relies* on grid order for its positional cycling. Both + contracts are load-bearing, so both are asserted here. + """ + grid = make_grid([("a", 2000.0, None), ("b", 1000.0, None)]) + assert single_phase_candidates(grid, usage_factor=0.5) == [("a", 1000.0), ("b", 500.0)] + + +def test_candidates_capacity_cutoff_moves_with_usage_factor(): + """The eligibility cut-off shifts with the usage factor. + + What: a 5 kW nameplate load is a candidate at 0.5x usage (2.5 kW + effective, under the ~3.3 kW single-leg cap) but not at 1.0x + (5 kW effective, over it). + + Why it matters: the usage factor is not cosmetic. It changes the + physical question "can this load sit on one 16 A leg?" — and so + changes the candidate set itself, not just the reported totals. + A regression that applied the factor only to reporting would + pass the round-robin tests but fail here. + """ + grid = make_grid([("borderline", 5000.0, None)]) + assert [n for n, _ in single_phase_candidates(grid, usage_factor=0.5)] == ["borderline"] + assert single_phase_candidates(grid, usage_factor=1.0) == [] + + +# --- _fixed_leg_seed --------------------------------------------------------- + + +def test_fixed_leg_seed_spreads_balanced_and_lands_phased(): + """The fixed-leg seed handles every non-candidate load form correctly. + + What: a balanced load spreads evenly (1/3 per leg), an int-phase + load lands wholly on its leg, a list-phase load splits evenly + across its listed legs — while the generator and any name passed + in `candidate_names` contribute nothing. + + Why it matters: the seed is the leg loading a strategy *starts + from* — greedy balances around it. If any of the three phase + forms were mis-handled, every greedy assignment on a grid with + pre-existing phased loads would be silently skewed, and the bug + would be invisible on the all-unphased fixtures. + """ + grid = make_grid( + [ + ("balanced", 3000.0, None), # -> 1000 on each leg + ("on_l1", 2000.0, 1), # -> 2000 on L1 + ("split", 2000.0, [2, 3]), # -> 1000 on L2, 1000 on L3 + ("a_candidate", 1000.0, None), # excluded via candidate_names + ], + gen_power=5000.0, # generator never contributes + ) + legs = _fixed_leg_seed(grid, candidate_names={"a_candidate"}, usage_factor=1.0) + assert legs == [1000.0 + 2000.0, 1000.0 + 1000.0, 1000.0 + 1000.0] + + +def test_fixed_leg_seed_warns_on_string_phase_marker(caplog): + """A node with a legacy string `phase` marker triggers a WARNING. + + What: a 3 kW load with `phase="Y"` is still spread evenly across + the three legs (`1 kW` each) by the seed, and a single + `WARNING`-level log record names the node and the marker. + + Why it matters: the seed is the silent path — it handles loads + that aren't being distributed by the current strategy, so a + weird `phase` value would be folded into the leg totals with no + other signal. The warning gives the operator a single-call + paper trail equivalent to the one `to_pandapower_3ph` emits. + """ + import logging + + grid = make_grid([("stage_left", 3000.0, "Y")]) + caplog.set_level(logging.WARNING, logger="nopywer.pp_interop.phases._common") + + legs = _fixed_leg_seed(grid, candidate_names=set(), usage_factor=1.0) + assert legs == [1000.0, 1000.0, 1000.0] + + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "stage_left" in warnings[0].getMessage() + assert "'Y'" in warnings[0].getMessage() + + +# --- build_assignment -------------------------------------------------------- + + +def test_build_assignment_sums_seed_and_placements(): + """`build_assignment` adds candidate placements on top of the seed. + + What: with a balanced 3 kW load (seed: 1 kW/leg) and a 1.2 kW + candidate placed on L2, the leg totals are (1000, 2200, 1000) and + the usage factor is recorded on the result. + + Why it matters: this is the one place leg totals are computed, so + every strategy reports them the same way. The test proves the two + contributions — the fixed seed and the strategy's own placements — + are both counted, exactly once each. + """ + grid = make_grid([("balanced", 3000.0, None), ("c", 1200.0, None)]) + # seed: balanced spreads 1000/leg. placement: c (1200 W) onto L2. + assignment = build_assignment(grid, {"c": 2}, usage_factor=1.0) + assert assignment.leg_totals_w == (1000.0, 1000.0 + 1200.0, 1000.0) + assert assignment.usage_factor == 1.0 + + +def test_build_assignment_balance_pct_zero_when_level_and_when_empty(): + """`balance_pct` is well-defined at both ends of the range. + + What: a single load dumped entirely on L1 gives a clearly + non-zero imbalance; a grid with no load at all gives exactly 0.0 + and zeroed leg totals. + + Why it matters: the empty-grid case is the div-by-zero trap — + `mean` is 0, and the metric must be *defined* as 0.0 there rather + than raising or producing NaN. Without this guard, calling a + strategy on a load-free sub-grid would crash. + """ + level = build_assignment(make_grid([("x", 900.0, None)]), {"x": 1}, usage_factor=1.0) + # x on L1 only -> (900, 0, 0) -> heavily imbalanced, definitely not 0 + assert level.balance_pct > 0 + + empty = build_assignment(make_grid([]), {}, usage_factor=1.0) + assert empty.balance_pct == 0.0 + assert empty.leg_totals_w == (0.0, 0.0, 0.0) + + +def test_build_assignment_balance_pct_matches_std_over_mean(): + """`balance_pct` is exactly 100 x std / mean of the three leg totals. + + What: for a known imbalanced seed (3000, 1500, 0), the metric + matches the std/mean formula computed independently in the test. + + Why it matters: this formula is deliberately the same one + `io.print_grid_info` already uses for its "phase balance" line. + Keeping them identical means a strategy's `balance_pct` is + directly comparable to the number a planner already sees in grid + summaries — a divergence here would make the two silently + incomparable. + """ + grid = make_grid([("a", 3000.0, 1), ("b", 1500.0, 2)]) + # both pre-phased, so candidate set is empty; seed = (3000, 1500, 0) + assignment = build_assignment(grid, {}, usage_factor=1.0) + mean = 4500.0 / 3 + variance = ((3000 - mean) ** 2 + (1500 - mean) ** 2 + (0 - mean) ** 2) / 3 + assert assignment.balance_pct == pytest.approx(100.0 * variance**0.5 / mean) + + +def test_build_assignment_raises_on_unknown_node(): + """Naming a node that is not on the grid fails loud, not silent. + + What: `build_assignment` with a `phases` mapping referencing a + non-existent node raises `KeyError`. + + Why it matters: the usual cause is an assignment computed against + a different grid object. Silently skipping the unknown name would + produce a plausible-looking but wrong result; a hard `KeyError` + surfaces the mistake immediately. + """ + with pytest.raises(KeyError): + build_assignment(make_grid([("real", 1000.0, None)]), {"ghost": 1}, usage_factor=1.0) + + +# --- assign_round_robin ------------------------------------------------------ + + +def test_round_robin_cycles_legs_in_grid_order(): + """Round-robin assigns L1, L2, L3, L1, ... down the grid order. + + What: four equal loads `a, b, c, d` get legs 1, 2, 3, 1. + + Why it matters: this *is* Strategy B — purely positional cycling. + The test pins the exact sequence so a change to the cycling rule + (off-by-one, wrong modulus, wrong iteration order) can't slip + through. + """ + grid = make_grid([(n, 1000.0, None) for n in ("a", "b", "c", "d")]) + assignment = assign_round_robin(grid, usage_factor=1.0) + assert assignment.phases == {"a": 1, "b": 2, "c": 3, "d": 1} + + +def test_round_robin_is_reproducible(): + """Round-robin is a pure function — same grid in, same plan out. + + What: two calls on the same grid produce identical `phases`. + + Why it matters: phase plans get committed to fixtures and acted on + in the field. Any hidden state or non-determinism would make the + fixture and a re-run disagree, which is exactly the kind of drift + that erodes trust in a planning tool. + """ + grid = make_grid([(n, 1500.0, None) for n in ("a", "b", "c")]) + assert ( + assign_round_robin(grid, usage_factor=0.5).phases + == assign_round_robin(grid, usage_factor=0.5).phases + ) + + +def test_round_robin_usage_factor_changes_totals_not_legs(): + """For round-robin the usage factor rescales totals but never moves a load. + + What: running at 0.5x and 1.0x gives identical `phases` but + leg totals that differ by exactly the 2x factor. + + Why it matters: round-robin is positional *by design* — the + factor must not leak into the leg choice. This is the property + that distinguishes B from D (where the factor genuinely changes + the assignment), so it is worth pinning explicitly. + """ + grid = make_grid([(n, 1000.0, None) for n in ("a", "b", "c")]) + half = assign_round_robin(grid, usage_factor=0.5) + full = assign_round_robin(grid, usage_factor=1.0) + assert half.phases == full.phases + assert half.leg_totals_w == tuple(v / 2 for v in full.leg_totals_w) + + +def test_round_robin_empty_when_no_candidates(): + """Round-robin produces an empty plan when nothing is eligible. + + What: a grid whose every load already carries a `phase` yields + `phases == {}`. + + Why it matters: a strategy must be a no-op on an already-phased + grid, not re-phase loads the planner has deliberately set. The + empty result is also what lets `build_assignment` describe a grid + that is fully fixed — it should not error or invent placements. + """ + grid = make_grid([("a", 1000.0, 1), ("b", 1000.0, 2)]) + assert assign_round_robin(grid, usage_factor=0.5).phases == {} + + +# --- assign_greedy ----------------------------------------------------------- + + +def test_greedy_places_heaviest_first_onto_lightest_leg(): + """Greedy follows the longest-processing-time rule, step by step. + + What: a 3 kW load plus three 1 kW loads, all eligible at usage + 1.0. Hand-traced: + sorted heaviest-first: a(3000), then b, c, d (1000 each) + a -> L1 legs (3000, 0, 0) + b -> L2 (lightest) legs (3000, 1000, 0) + c -> L3 (lightest) legs (3000, 1000, 1000) + d -> L2 (tie L2/L3 -> lowest index) legs (3000, 2000, 1000) + + Why it matters: this pins the whole heuristic — the heaviest-first + sort, the lightest-leg pick, and the lowest-index tie-break — to + one fully-worked example. Any single piece breaking changes the + expected `phases` or `leg_totals_w`. + """ + grid = make_grid( + [("a", 3000.0, None), ("b", 1000.0, None), ("c", 1000.0, None), ("d", 1000.0, None)] + ) + assignment = assign_greedy(grid, usage_factor=1.0) + assert assignment.phases == {"a": 1, "b": 2, "c": 3, "d": 2} + assert assignment.leg_totals_w == (3000.0, 2000.0, 1000.0) + + +def test_greedy_beats_round_robin_on_uneven_load_sizes(): + """Greedy produces a lower imbalance than round-robin — the reason it exists. + + What: on a grid that interleaves one heavy load among light ones — + round-robin's worst case, since position and size are unrelated — + greedy's `balance_pct` is strictly lower. + + Why it matters: this is the entire justification for having a + second, size-aware strategy. If greedy ever failed to beat + round-robin on uneven sizes it would not be worth its extra + complexity, and doc 10's recommendation would be wrong. + """ + # Grid order interleaves one heavy load among light ones — the worst + # case for round-robin's positional assignment. + grid = make_grid( + [("a", 3000.0, None), ("b", 1000.0, None), ("c", 1000.0, None), ("d", 1000.0, None)] + ) + rr = assign_round_robin(grid, usage_factor=1.0) + greedy = assign_greedy(grid, usage_factor=1.0) + assert greedy.balance_pct < rr.balance_pct + + +def test_greedy_balances_around_the_fixed_seed(): + """Greedy levels the whole grid, including loads it did not place. + + What: a pre-assigned 4 kW load already sits on L1; greedy's two + 2 kW candidates both go to L2 and L3, never piling onto the + already-heavy L1. Final legs: (4000, 2000, 2000). + + Why it matters: greedy seeds its leg tally from `_fixed_leg_seed`, + not from zero. If it ignored the seed it would treat the grid as + empty and could stack candidates straight onto L1 — making a grid + that was already lopsided worse. This proves it balances around + what is already there. + """ + grid = make_grid( + [ + ("preassigned", 4000.0, 1), # fixed seed: 4000 on L1 + ("x", 2000.0, None), # candidate + ("y", 2000.0, None), # candidate + ] + ) + assignment = assign_greedy(grid, usage_factor=1.0) + # both candidates should avoid the already-heavy L1 + assert set(assignment.phases.values()) == {2, 3} + assert assignment.leg_totals_w == (4000.0, 2000.0, 2000.0) + + +def test_greedy_is_reproducible_with_deterministic_tie_break(): + """Greedy is deterministic — equal-load ties always break to the lowest leg. + + What: three equal loads land on L1, L2, L3 (each step ties across + the remaining empty legs and picks the lowest index), and two + runs produce identical `phases`. + + Why it matters: `min` over equal values is only deterministic if + the tie-break is defined. Without a stable rule, greedy could + return different plans on different runs or platforms — the same + drift trap as the round-robin reproducibility test, and just as + corrosive to a committed fixture. + """ + grid = make_grid([(n, 1000.0, None) for n in ("a", "b", "c")]) + first = assign_greedy(grid, usage_factor=0.5) + second = assign_greedy(grid, usage_factor=0.5) + assert first.phases == second.phases == {"a": 1, "b": 2, "c": 3} + + +# --- apply_assignment -------------------------------------------------------- + + +def test_apply_assignment_mutates_candidates_only(): + """`apply_assignment` writes only the candidate loads, nothing else. + + What: after applying, a candidate has its assigned phase; a + pre-phased load keeps its original phase; the generator stays + `None`. + + Why it matters: applying a plan must not clobber loads the planner + deliberately phased, nor invent a phase for the generator or a + transit node. The blast radius of a write-back is exactly the + candidate set — this proves it. + """ + grid = make_grid( + [("cand", 2000.0, None), ("preassigned", 2000.0, 3)], + gen_power=1000.0, + ) + assignment = assign_greedy(grid, usage_factor=1.0) + apply_assignment(grid, assignment) + assert grid.nodes["cand"].phase == assignment.phases["cand"] + assert grid.nodes["preassigned"].phase == 3 # unchanged + assert grid.nodes["gen"].phase is None # unchanged + + +def test_apply_assignment_is_idempotent(): + """Applying the same plan twice is identical to applying it once. + + What: a snapshot of node phases after one apply equals the state + after a second apply. + + Why it matters: idempotence means `apply_assignment` has no + accumulating side effect — re-running a pipeline that applies a + plan can't corrupt the grid. It also documents that apply is a + plain write, not a toggle or an increment. + """ + grid = make_grid([(n, 1000.0, None) for n in ("a", "b", "c")]) + assignment = assign_round_robin(grid, usage_factor=0.5) + apply_assignment(grid, assignment) + snapshot = {n: grid.nodes[n].phase for n in assignment.phases} + apply_assignment(grid, assignment) + assert {n: grid.nodes[n].phase for n in assignment.phases} == snapshot + + +def test_apply_assignment_raises_on_unknown_node(): + """Applying a plan built for a different grid fails loud. + + What: an assignment naming a node absent from the grid raises + `KeyError` on apply. + + Why it matters: same failure mode as the `build_assignment` + version — mixing up grid objects is an easy mistake, and a hard + error at the write-back catches it before a bogus plan is + committed to a fixture. + """ + grid = make_grid([("real", 1000.0, None)]) + stray = PhaseAssignment( + phases={"ghost": 1}, usage_factor=1.0, leg_totals_w=(0, 0, 0), balance_pct=0 + ) + with pytest.raises(KeyError): + apply_assignment(grid, stray) + + +# --- defaults ---------------------------------------------------------------- + + +def test_reference_usage_factor_is_half(): + """The project-wide reference usage factor is 0.5x. + + What: `config.DEFAULT_USAGE_FACTOR == 0.5`, and it is exposed + via `pp_interop.phases` for callers that want to adopt the + project-wide value with one symbol. + + Why it matters: 0.5x is doc 10's stated starting assumption — + festival loads rarely draw nameplate together. The constant is + a *reference*, not a function default — every strategy requires + `usage_factor` to be passed explicitly so the choice is always + visible at the call site. Pinning the reference here means a + change to that assumption has to be deliberate (and shows up + as a failing test) rather than drifting in unnoticed. + """ + from nopywer.pp_interop import config + + assert DEFAULT_USAGE_FACTOR == 0.5 + assert config.DEFAULT_USAGE_FACTOR is DEFAULT_USAGE_FACTOR + + +# --- integration on the real field export ------------------------------------ + + +def test_strategies_run_on_field_export_and_greedy_balances_better(): + """Both strategies handle the real 2026 export; greedy wins on balance. + + What: loaded from the unmodified `2026-05-14_martin.geojson` + field export — every load arrives unphased, so the full candidate + path runs — both strategies produce a non-trivial assignment over + the same set of loads, every leg is valid (1/2/3), and greedy's + `balance_pct` beats round-robin's. + + Why it matters: the unit tests above use tidy synthetic grids; this + is the proof the strategies survive a real fixture's messiness + (mixed load sizes, multi-phase loads already present, zero-power + distros). The greedy-beats-round-robin check is the same property + as the synthetic test, but confirmed on production-shaped data — + which is what doc 10's recommendation actually rests on. + """ + grid = PowerGrid.from_geojson(FIXTURES / "2026-05-14_martin.geojson") + + rr = assign_round_robin(grid, usage_factor=DEFAULT_USAGE_FACTOR) + greedy = assign_greedy(grid, usage_factor=DEFAULT_USAGE_FACTOR) + + # the export has plenty of small loads -> a non-trivial candidate set + assert len(rr.phases) > 5 + assert rr.phases.keys() == greedy.phases.keys() + # every assignment is a valid leg + assert all(leg in (1, 2, 3) for leg in greedy.phases.values()) + # the reason Strategy D exists + assert greedy.balance_pct < rr.balance_pct + + +def test_phase_assignment_round_trips_through_geojson(tmp_path): + """Planned phases survive a `to_geojson` → `load_geojson` cycle. + + What: plan with greedy, apply, export the grid, reload it, + re-plan with the same factor — the second plan finds nothing left + to do (all candidates are already phased) and the per-node phases + on the reloaded grid match the original assignment exactly. + + Why it matters: this is the contract that lets a planner commit + a plan to a fixture and have a future run see it. Without it the + `apply_assignment` → `grid.to_geojson` → file → `load_geojson` + pipeline silently drops phase data, and "I already planned this + grid" becomes unobservable on reload. + """ + import json + + from nopywer.io import load_geojson + + grid = PowerGrid.from_geojson(FIXTURES / "2026-05-14_martin.geojson") + plan = assign_greedy(grid, usage_factor=DEFAULT_USAGE_FACTOR) + apply_assignment(grid, plan) + assert plan.phases # sanity: there was something to assign + + out_path = tmp_path / "phased.geojson" + with open(out_path, "w") as f: + json.dump(grid.to_geojson(), f) + + reloaded_nodes, reloaded_cables = load_geojson(out_path) + reloaded = PowerGrid(nodes=reloaded_nodes, cables=reloaded_cables) + + # Every planned phase appears on the reloaded grid, unchanged. + for name, leg in plan.phases.items(): + assert reloaded.nodes[name].phase == leg + + # A second planning pass on the reloaded grid is a no-op: the + # loads we just phased are no longer candidates. + second = assign_greedy(reloaded, usage_factor=DEFAULT_USAGE_FACTOR) + assert set(second.phases).isdisjoint(plan.phases) + + +# --- phase_geojson (one-call wrapper) ---------------------------------------- + + +def test_phase_geojson_matches_step_by_step_pipeline(tmp_path): + """`phase_geojson` is equivalent to running the primitives by hand. + + What: calling the wrapper on the field export with the greedy + strategy produces a file whose per-node phases match what you + get from a manual `load → assign_greedy → apply → to_geojson` + pipeline, and the returned `PhaseAssignment` matches the + in-process one. + + Why it matters: the wrapper exists so scripts and the CLI don't + have to assemble the pipeline by hand. If it ever drifts from + the primitives the convenience becomes a footgun — a user + relying on `phase_geojson` would silently get a different plan + from a user reading the README's step-by-step snippet. + """ + from nopywer.io import load_geojson + + src = FIXTURES / "2026-05-14_martin.geojson" + + # Manual pipeline (the reference) + manual_nodes, manual_cables = load_geojson(src) + manual_grid = PowerGrid(nodes=manual_nodes, cables=manual_cables) + manual_plan = assign_greedy(manual_grid, usage_factor=DEFAULT_USAGE_FACTOR) + apply_assignment(manual_grid, manual_plan) + + # Wrapper + out_path = tmp_path / "phased.geojson" + wrapper_plan = phase_geojson( + src, + out_path, + usage_factor=DEFAULT_USAGE_FACTOR, + strategy="greedy", + ) + + assert wrapper_plan.phases == manual_plan.phases + assert wrapper_plan.balance_pct == manual_plan.balance_pct + + # The written file matches the manual grid node-for-node on phase. + reloaded_nodes, _ = load_geojson(out_path) + for name, node in manual_grid.nodes.items(): + assert reloaded_nodes[name].phase == node.phase + + +def test_phase_geojson_strategy_selection_changes_result(tmp_path): + """The `strategy` argument actually picks a different strategy. + + What: running the wrapper with `"greedy"` and with `"round_robin"` + on the same fixture produces different `balance_pct` values + (greedy lower), matching the property pinned for the underlying + strategies. + + Why it matters: a string-keyed dispatch is easy to mis-wire (e.g. + both keys pointing at the same function); this proves the two + keys reach the two distinct strategies. + """ + src = FIXTURES / "2026-05-14_martin.geojson" + greedy_plan = phase_geojson( + src, tmp_path / "g.geojson", usage_factor=DEFAULT_USAGE_FACTOR, strategy="greedy" + ) + rr_plan = phase_geojson( + src, + tmp_path / "r.geojson", + usage_factor=DEFAULT_USAGE_FACTOR, + strategy="round_robin", + ) + assert greedy_plan.balance_pct < rr_plan.balance_pct + + +def test_phase_geojson_rejects_unknown_strategy(tmp_path): + """An unknown strategy name fails loud with a list of valid keys. + + What: passing `strategy="bogus"` raises `ValueError` and the + message names the accepted strategies. + + Why it matters: the closed string-literal set is part of the + wrapper's contract — a typo at a script call site should fail + fast with an actionable message, not silently fall through to a + default or get a `KeyError` from a dict lookup later. + """ + with pytest.raises(ValueError, match="greedy"): + phase_geojson( + FIXTURES / "2026-05-14_martin.geojson", + tmp_path / "out.geojson", + usage_factor=DEFAULT_USAGE_FACTOR, + strategy="bogus", # type: ignore[arg-type] + ) diff --git a/tests/test_pp_interop_powerflow_3ph.py b/tests/test_pp_interop_powerflow_3ph.py new file mode 100644 index 0000000..f2a404b --- /dev/null +++ b/tests/test_pp_interop_powerflow_3ph.py @@ -0,0 +1,344 @@ +"""Tests for asymmetric (unbalanced three-phase) AC power flow. + +`_powerflow_3ph` runs `pp.runpp_3ph` — the solve that, unlike the +balanced `runpp`, sees per-leg voltage drop and neutral-conductor +current. These tests check three things: + + - `_phase_split` turns a `PowerNode.phase` field into a per-leg + power tuple the same way the rest of nopywer does; + - `to_pandapower_3ph` builds an independent `Pandapower3phGrid` + with zero-sequence params on lines and ext_grid, balanced loads + routed to `net.load` and explicitly-phased loads to + `net.asymmetric_load`; + - `compute_power_flow_3ph` converges and returns results with the + physical signatures we expect: a balanced load draws ~no neutral + current and sags all three legs equally; a single-phase load + sags its own leg and returns its whole current down the neutral. + +Grids are tiny and hand-built so the physics is obvious. For +real-fixture behaviour see the field-export work in research doc 11. +""" + +import pytest + +pytest.importorskip("pandapower") + +from nopywer.models import Cable32A, PowerGrid, PowerNode +from nopywer.pp_interop import ( + PowerFlow3phResults, + compute_power_flow_3ph, + config, + to_pandapower_3ph, +) +from nopywer.pp_interop._powerflow_3ph import _phase_split + + +def _grid(loads: list[tuple[str, float, object]]) -> PowerGrid: + """Generator + one 50 m Cable32A per load, `(name, watts, phase)`.""" + nodes = {"generator": PowerNode("generator", 0.0, 0.0, is_generator=True)} + cables = {} + for i, (name, watts, phase) in enumerate(loads): + nodes[name] = PowerNode(name, 0.001 * (i + 1), 0.0, power_watts=watts, phase=phase) + cables[f"c{i}"] = Cable32A(id=f"c{i}", length_m=50.0, from_node="generator", to_node=name) + return PowerGrid(nodes=nodes, cables=cables) + + +# --- _phase_split ------------------------------------------------------------ + + +@pytest.mark.parametrize( + "phase, expected", + [ + (1, (9000.0, 0.0, 0.0)), + (2, (0.0, 9000.0, 0.0)), + (3, (0.0, 0.0, 9000.0)), + ([1, 2], (4500.0, 4500.0, 0.0)), + ([1, 2, 3], (3000.0, 3000.0, 3000.0)), + (None, (3000.0, 3000.0, 3000.0)), + ("U", (3000.0, 3000.0, 3000.0)), # sub-grid marker -> balanced + ([], (3000.0, 3000.0, 3000.0)), # empty list -> balanced + ([9], (3000.0, 3000.0, 3000.0)), # no valid legs -> balanced + ], +) +def test_phase_split(phase, expected): + """Every form of `PowerNode.phase` maps to the right per-leg power tuple. + + What: `_phase_split` is exercised across all six cases — int legs + 1/2/3, a two-leg list, a full three-leg list, `None`, the `"U"` + sub-grid marker, and two malformed lists (empty, no valid leg). + Each must produce the documented per-leg split, and every split + must conserve power (sum back to the 9 kW input). + + Why it matters: this function is the bridge between nopywer's + polymorphic `phase` field and pandapower's asymmetric loads. If it + disagreed with the rule `io.load_geojson` uses, `runpp_3ph` would + be solving a different grid than the tree walk — the two would be + silently incomparable. The malformed-input rows pin the + fall-through-to-balanced behaviour so a bad fixture degrades + safely instead of crashing the solve. + """ + result = _phase_split(phase, 9000.0) + assert result == expected + assert sum(result) == pytest.approx(9000.0) + + +# --- to_pandapower_3ph ------------------------------------------------------- + + +def test_to_3ph_routes_phased_loads_to_the_asymmetric_table(): + """A phased load lands in `net.asymmetric_load`; a balanced load in `net.load`. + + What: a grid with one balanced and one L1 load converts to a net + with exactly one `pp.load` and one `pp.asymmetric_load`, and the + `Pandapower3phGrid`'s two index maps each name exactly the + expected member — `balanced_load_idx == {"balanced"}` and + `asymmetric_load_idx == {"on_l1"}`, with no overlap. + + Why it matters: `to_pandapower_3ph` decides per load which + pandapower table to put it in based on `node.phase`. The + distinct index maps are the only way a downstream caller can + tell which table a load is in without re-inspecting the net. + A phased load that landed in the balanced table — or appeared + in both maps — would be silently counted with the wrong model + by `runpp_3ph`. + """ + grid = _grid([("balanced", 3000.0, None), ("on_l1", 3000.0, 1)]) + pp_grid = to_pandapower_3ph(grid) + + assert len(pp_grid.net.load) == 1 # only the balanced one + assert len(pp_grid.net.asymmetric_load) == 1 # the phased one + assert set(pp_grid.balanced_load_idx) == {"balanced"} + assert set(pp_grid.asymmetric_load_idx) == {"on_l1"} + # A load is in at most one map; no name appears in both. + assert set(pp_grid.balanced_load_idx).isdisjoint(pp_grid.asymmetric_load_idx) + + +def test_to_3ph_sets_zero_sequence_line_params(): + """Every line gets r0 / x0 / c0 derived from the config ratios. + + What: a line's `r0_ohm_per_km` is its positive-sequence + `r_ohm_per_km` times `R0_OVER_R1`, and likewise for x0; `c0` is + the configured zero-sequence capacitance. + + Why it matters: `runpp_3ph` cannot solve a line that has no + zero-sequence parameters — they describe how the cable behaves to + the imbalance current returning through neutral and earth. This is + a wiring-regression guard (it asserts the formula the code + applies), not a physics check: its job is to catch someone + dropping or rescaling the zero-sequence assignment, which would + make `runpp_3ph` either fail outright or silently model the wrong + cable. + """ + grid = _grid([("load", 3000.0, None)]) + net = to_pandapower_3ph(grid).net + line = net.line.iloc[0] + assert line["r0_ohm_per_km"] == pytest.approx(line["r_ohm_per_km"] * config.R0_OVER_R1) + assert line["x0_ohm_per_km"] == pytest.approx(line["x_ohm_per_km"] * config.X0_OVER_X1) + assert line["c0_nf_per_km"] == config.C0_NF_PER_KM + + +def test_to_3ph_sets_source_vector_group(): + """The ext_grid carries the genset's zero-sequence return ratios. + + What: the converted ext_grid has `r0x0_max` and `x0x_max` set to + the `SOURCE_*` config values. + + Why it matters: these describe whether the source's neutral + provides a return path for zero-sequence current — a Yn grounded + genset versus an isolated IT one behave completely differently. + Without them `runpp_3ph` has no source-side zero-sequence model. + Like the line-params test this is a wiring-regression guard, not + a physics check. + """ + net = to_pandapower_3ph(_grid([("load", 3000.0, None)])).net + assert net.ext_grid.at[0, "r0x0_max"] == config.SOURCE_R0X0_MAX + assert net.ext_grid.at[0, "x0x_max"] == config.SOURCE_X0X_MAX + + +def test_to_3ph_all_balanced_grid_has_no_asymmetric_loads(): + """A grid with no phased loads produces no asymmetric_load entries. + + What: when every load is balanced (`phase` is `None`), the + converted net has an empty `asymmetric_load` table and keeps every + load as a plain `pp.load`. + + Why it matters: the converter must only swap loads that actually + carry a phase. A bug that asymmetric-ised balanced loads would + work numerically — three equal legs is still balanced — but would + bloat the net and, more importantly, mean the swap logic was + triggering on the wrong condition. This is the negative case that + pins it: no phases in, no asymmetric loads out. + """ + grid = _grid([("a", 3000.0, None), ("b", 2000.0, None)]) + pp_grid = to_pandapower_3ph(grid) + assert len(pp_grid.net.asymmetric_load) == 0 + assert len(pp_grid.net.load) == 2 + + +def test_to_3ph_routes_string_phase_marker_to_balanced_table_with_warning(caplog): + """A node whose `phase` is a legacy string marker is treated as balanced. + + What: a load with `phase="U"` ends up in `net.load` (and + `balanced_load_idx`), not `net.asymmetric_load`, and a single + `WARNING`-level log record names the node and the marker. + + Why it matters: `"U"` / `"Y"` are sub-grid reporting markers + with no electrical meaning. Routing them to `asymmetric_load` + would store a "no electrical meaning" marker as if it were a + deliberate per-leg split — functionally equivalent (three + equal P's behave like a balanced load) but semantically + misleading. Sending them to the balanced table is the honest + representation; the warning is the operator's signal that the + marker propagated this far. + """ + import logging + + grid = _grid([("stage_left", 3000.0, "U")]) + caplog.set_level(logging.WARNING, logger="nopywer.pp_interop._powerflow_3ph") + + pp_grid = to_pandapower_3ph(grid) + + assert "stage_left" in pp_grid.balanced_load_idx + assert "stage_left" not in pp_grid.asymmetric_load_idx + assert len(pp_grid.net.load) == 1 + assert len(pp_grid.net.asymmetric_load) == 0 + + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "stage_left" in warnings[0].getMessage() + assert "'U'" in warnings[0].getMessage() + + +# --- compute_power_flow_3ph -------------------------------------------------- + + +def test_balanced_load_sags_all_legs_equally_with_no_neutral_current(): + """A balanced load is the symmetric case: equal legs, ~zero neutral. + + What: a single balanced 6 kW load converges, sits at the same + voltage on all three legs, draws essentially no neutral current, + and still shows a real (non-zero) voltage drop. + + Why it matters: this is the physics sanity floor for the + asymmetric solver — when the load *is* balanced, `runpp_3ph` must + reproduce the symmetric result: no negative- or zero-sequence + component, so nothing in the neutral and no per-leg spread. The + "drop > 0" check rules out the degenerate pass where the solver + returns a flat nominal grid because the load was silently + dropped. (Cross-checked against the balanced `runpp` separately: + both land on 227.17 V.) + """ + grid = _grid([("load", 6000.0, None)]) + res = compute_power_flow_3ph(to_pandapower_3ph(grid)) + + assert res.converged + v_l1, v_l2, v_l3 = res.bus_voltage_v["load"] + assert v_l1 == pytest.approx(v_l2, abs=0.1) + assert v_l2 == pytest.approx(v_l3, abs=0.1) + # the imbalance return is essentially nothing + assert res.line_neutral_current_a["c0"] == pytest.approx(0.0, abs=0.5) + # and there is a real drop — the load is being fed, not idle + assert res.bus_vdrop_percent["load"][0] > 0 + + +def test_single_phase_load_sags_its_own_leg_and_loads_the_neutral(): + """A single-phase load sags its own leg and returns its whole current + on the neutral. + + What: a single 6 kW load on L1 converges; L1 is the lowest of the + three legs and drops more than 5 %; L2 and L3 draw essentially no + current; and the cable's neutral current equals its L1 current. + + Why it matters: this is the asymmetric case the whole module + exists for — the one a balanced `runpp` structurally cannot show. + Two independent physics facts are pinned: (1) a purely + single-phase draw has no return path *but* the neutral, so + `i_neutral == i_L1` is a hard identity, not a fitted number; (2) + the loaded leg must sag hardest. The `> 5 %` bound is deliberately + loose — hand arithmetic puts the real drop near 9 % (R per + conductor ~0.32 ohm, ~32 A, P-N drop ~20 V) — so it tests the + sign and scale without over-fitting to the solver's exact output. + + Note the two *unloaded* legs do NOT sit at equal voltage: L2 and + L3 couple differently to L1's current through the line + impedances, so a single-phase load makes the bus genuinely + asymmetric. That asymmetry is precisely why a `runpp_3ph` solve + is needed at all — which is why this test does not assert + L2 == L3 (an earlier version did, and it was wrong physics). + """ + grid = _grid([("on_l1", 6000.0, 1)]) + res = compute_power_flow_3ph(to_pandapower_3ph(grid)) + + assert res.converged + v_l1, v_l2, v_l3 = res.bus_voltage_v["on_l1"] + # L1 carries the load and sags hardest of the three + assert v_l1 < v_l2 + assert v_l1 < v_l3 + assert res.bus_vdrop_percent["on_l1"][0] > 5 # a real, sizeable sag + + i_l1, i_l2, i_l3 = res.line_current_a["c0"] + assert i_l1 > 0 + assert i_l2 == pytest.approx(0.0, abs=0.5) + assert i_l3 == pytest.approx(0.0, abs=0.5) + # the whole phase current returns on the neutral + assert res.line_neutral_current_a["c0"] == pytest.approx(i_l1, rel=0.05) + + +def test_results_helpers_find_worst_leg_and_neutral(): + """The result helpers pick out the genuinely worst leg and cable. + + What: with a light 2 kW load on L1 and a heavy 6 kW load on L2, + `worst_phase_vdrop` returns `heavy_l2`'s L2 (and that drop is the + max over every node and leg), and `worst_neutral_current` returns + `heavy_l2`'s feeder cable (and that current is the max over every + cable). + + Why it matters: these helpers are what a planner actually reads — + "where is the grid worst?" The test confirms they search across + *all* nodes/legs/cables rather than, say, returning the first or + last entry. The heavier single-phase load must sag its own leg + most and load its own feeder's neutral most, so the expected + answers are derivable by hand, not read off the solver. + """ + # two single-phase loads, the heavier one on L2 + grid = _grid([("light_l1", 2000.0, 1), ("heavy_l2", 6000.0, 2)]) + res = compute_power_flow_3ph(to_pandapower_3ph(grid)) + + worst = res.worst_phase_vdrop() + assert worst is not None + name, leg, drop = worst + assert name == "heavy_l2" + assert leg == 2 + assert drop == max(d for legs in res.bus_vdrop_percent.values() for d in legs) + + worst_n = res.worst_neutral_current() + assert worst_n is not None + cid, current = worst_n + # heavy_l2's feeder carries the most neutral current + assert cid == "c1" + assert current == max(res.line_neutral_current_a.values()) + + +def test_results_helpers_return_none_on_empty(): + """The result helpers return None on empty results instead of raising. + + What: a `PowerFlow3phResults` with empty dicts yields `None` from + both `worst_phase_vdrop` and `worst_neutral_current`. + + Why it matters: the helpers use `max(...)`, which raises + `ValueError` on an empty iterable. An empty result is a real + possibility — a grid with no lines, or a caller inspecting a + partially-built result — and the helpers must degrade to a clean + `None` the caller can branch on, not blow up. + """ + empty = PowerFlow3phResults( + bus_voltage_v={}, + bus_vdrop_percent={}, + bus_unbalance_percent={}, + line_current_a={}, + line_neutral_current_a={}, + line_loading_percent={}, + converged=True, + ) + assert empty.worst_phase_vdrop() is None + assert empty.worst_neutral_current() is None