From 5e1172355697f6e272a37e6c0f5ff3cc6e665c49 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Thu, 14 May 2026 22:02:35 +0200 Subject: [PATCH 01/19] feat: support field-export GeoJSON and asymmetric per-load phases io.load_geojson now accepts the plugin export schema (area_mm2, plugs_and_sockets_a, length_m) alongside the input schema, and handles list-valued phase to model multi-phase loads (e.g. phase=[1,2] splits power evenly across L1 and L2). Adds Martin's 2026 east-grid field export as a fixture plus a modified copy with resized trunk/branch cables, and research doc 11 walking through plugging the export into the pandapower code. Co-Authored-By: Claude Opus 4.7 --- .../11_field_export_fixture_walkthrough.md | 182 +++ research/pandapower/README.md | 7 + src/nopywer/io.py | 22 +- tests/fixtures/2026-05-14_martin.geojson | 1417 ++++++++++++++++ .../2026-05-14_martin_modified.geojson | 1418 +++++++++++++++++ 5 files changed, 3045 insertions(+), 1 deletion(-) create mode 100644 research/pandapower/11_field_export_fixture_walkthrough.md create mode 100644 tests/fixtures/2026-05-14_martin.geojson create mode 100644 tests/fixtures/2026-05-14_martin_modified.geojson 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..9149217 --- /dev/null +++ b/research/pandapower/11_field_export_fixture_walkthrough.md @@ -0,0 +1,182 @@ +# 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 for +Martin and Vincent: + +- **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` — Martin's 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. + +## What this changes upstream + +Two things worth carrying back into the codebase rather than +leaving as research notes: + +- **`io.load_geojson` should accept the export schema.** Exports + round-tripping as inputs is a normal workflow; the silent-default + behaviour is a trap. Accept both key spellings. +- **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/README.md b/research/pandapower/README.md index 2f0a017..4d67f4f 100644 --- a/research/pandapower/README.md +++ b/research/pandapower/README.md @@ -49,6 +49,13 @@ Date of research: April 2026. Pandapower latest at time of writing: **3.4.0** 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. +11. [`11_field_export_fixture_walkthrough.md`](11_field_export_fixture_walkthrough.md) — + how to plug Martin's `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. ## TL;DR diff --git a/src/nopywer/io.py b/src/nopywer/io.py index 9a3bf0b..e826c3a 100644 --- a/src/nopywer/io.py +++ b/src/nopywer/io.py @@ -10,6 +10,20 @@ 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", +} + + +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,7 +42,7 @@ 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", {}) + props = _normalise_keys(feature.get("properties", {})) gtype = geom.get("type", "") if gtype == "Point": @@ -49,6 +63,12 @@ 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: node.power_per_phase += power / 3 nodes.append(node) 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..e789a30 --- /dev/null +++ b/tests/fixtures/2026-05-14_martin_modified.geojson @@ -0,0 +1,1418 @@ +{ + "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": {} + } + } + }, + { + "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, + "phase": [1, 2], + "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 From 325f1e1e4c66db6cc49b1b83709cb35b425f31bd Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Thu, 14 May 2026 22:08:24 +0200 Subject: [PATCH 02/19] docs(research): add cable-resize worked example to doc 11 Documents the iterative cable-resize exercise on the modified fixture: worst-bus trace, the resize sweep matrix (config x load scale -> max vdrop), and the finding that convergence is a far lower bar than the 5% drop threshold. Co-Authored-By: Claude Opus 4.7 --- .../11_field_export_fixture_walkthrough.md | 93 ++++++++++++++++++- 1 file changed, 88 insertions(+), 5 deletions(-) diff --git a/research/pandapower/11_field_export_fixture_walkthrough.md b/research/pandapower/11_field_export_fixture_walkthrough.md index 9149217..48b6cb7 100644 --- a/research/pandapower/11_field_export_fixture_walkthrough.md +++ b/research/pandapower/11_field_export_fixture_walkthrough.md @@ -158,14 +158,97 @@ For Martin, concretely: (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 (all 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. + ## What this changes upstream -Two things worth carrying back into the codebase rather than -leaving as research notes: +Carrying back into the codebase rather than leaving as research +notes: -- **`io.load_geojson` should accept the export schema.** Exports - round-tripping as inputs is a normal workflow; the silent-default - behaviour is a trap. Accept both key spellings. +- **`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 From 08b289190d690d4b9cef03c35c909afd6c63db68 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Thu, 14 May 2026 22:31:46 +0200 Subject: [PATCH 03/19] feat: add per-load phase distribution strategies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New pp_interop/phases package synthesises a leg (L1/L2/L3) for single-phase loads on grids that ship without phase data — the prerequisite for asymmetric runpp_3ph work (research doc 10). Two strategies in separate modules: round-robin (positional, cyclic legs) and greedy (size-aware, heaviest load onto the lightest leg). Both share candidate selection, a fixed-leg seed, and the balance metric in _common. Strategies are pure; commit a plan via apply_assignment. Applies the greedy assignment to the modified fixture and regenerates its distro in/out fields after the earlier cable resizes. Co-Authored-By: Claude Opus 4.7 --- .../11_field_export_fixture_walkthrough.md | 2 +- src/nopywer/pp_interop/phases/__init__.py | 38 +++ src/nopywer/pp_interop/phases/_common.py | 296 +++++++++++++++++ src/nopywer/pp_interop/phases/_greedy.py | 91 ++++++ src/nopywer/pp_interop/phases/_round_robin.py | 74 +++++ .../2026-05-14_martin_modified.geojson | 80 +++-- tests/test_pp_interop_phases.py | 304 ++++++++++++++++++ 7 files changed, 855 insertions(+), 30 deletions(-) create mode 100644 src/nopywer/pp_interop/phases/__init__.py create mode 100644 src/nopywer/pp_interop/phases/_common.py create mode 100644 src/nopywer/pp_interop/phases/_greedy.py create mode 100644 src/nopywer/pp_interop/phases/_round_robin.py create mode 100644 tests/test_pp_interop_phases.py diff --git a/research/pandapower/11_field_export_fixture_walkthrough.md b/research/pandapower/11_field_export_fixture_walkthrough.md index 48b6cb7..74d3ca8 100644 --- a/research/pandapower/11_field_export_fixture_walkthrough.md +++ b/research/pandapower/11_field_export_fixture_walkthrough.md @@ -197,7 +197,7 @@ solution at all: | Cable config | 1× | 0.7× | 0.6× | 0.5× | 0.3× | |---|---|---|---|---|---| -| S0 original (all 2.5 mm²) | — | — | — | 34 % | 16 % | +| 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 % | diff --git a/src/nopywer/pp_interop/phases/__init__.py b/src/nopywer/pp_interop/phases/__init__.py new file mode 100644 index 0000000..8483b4c --- /dev/null +++ b/src/nopywer/pp_interop/phases/__init__.py @@ -0,0 +1,38 @@ +"""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`. +""" + +from ._common import ( + DEFAULT_USAGE_FACTOR, + SINGLE_PHASE_CAPACITY_W, + PhaseAssignment, + apply_assignment, + single_phase_candidates, +) +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", + "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..eaecf18 --- /dev/null +++ b/src/nopywer/pp_interop/phases/_common.py @@ -0,0 +1,296 @@ +"""Shared scaffolding for per-load phase distribution strategies. + +A festival grid that will be solved with pandapower's asymmetric +`runpp_3ph` needs every single-phase load to declare which leg +(L1 / L2 / L3) it sits on. The strategies in this package fill that +gap in for fixtures that ship without phase data. + +Everything the individual strategies have in common lives here: + + - the **usage factor** — festival loads rarely draw nameplate + power simultaneously, so balancing decisions use an assumed + fraction of nameplate (default 0.5x). + - **candidate selection** — 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** used to compare strategies. + +A strategy is just a function `PowerGrid -> PhaseAssignment`. It does +not mutate the grid; call `apply_assignment` to write the result back. + +== 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. +""" + +from dataclasses import dataclass + +from ...constants import PF, V0 +from ...models import Cable16A, PowerGrid + +# 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 a deliberately conservative starting assumption (see doc 10). +DEFAULT_USAGE_FACTOR = 0.5 + +# 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 + `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 = DEFAULT_USAGE_FACTOR +) -> 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: assumed fraction of nameplate power loads draw + together. 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 + 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/_greedy.py b/src/nopywer/pp_interop/phases/_greedy.py new file mode 100644 index 0000000..1337941 --- /dev/null +++ b/src/nopywer/pp_interop/phases/_greedy.py @@ -0,0 +1,91 @@ +"""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 ( + DEFAULT_USAGE_FACTOR, + PhaseAssignment, + _fixed_leg_seed, + build_assignment, + single_phase_candidates, +) + + +def assign_greedy(grid: PowerGrid, usage_factor: float = DEFAULT_USAGE_FACTOR) -> 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: assumed fraction of nameplate power loads draw + together. 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) + 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..960d47b --- /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 ( + DEFAULT_USAGE_FACTOR, + PhaseAssignment, + build_assignment, + single_phase_candidates, +) + + +def assign_round_robin( + grid: PowerGrid, usage_factor: float = DEFAULT_USAGE_FACTOR +) -> 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: assumed fraction of nameplate power loads draw + together. 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) + 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_modified.geojson b/tests/fixtures/2026-05-14_martin_modified.geojson index e789a30..79346ac 100644 --- a/tests/fixtures/2026-05-14_martin_modified.geojson +++ b/tests/fixtures/2026-05-14_martin_modified.geojson @@ -820,7 +820,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 3 } }, { @@ -842,7 +843,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 2 } }, { @@ -864,7 +866,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 3 } }, { @@ -886,7 +889,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 2 } }, { @@ -908,7 +912,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 3 } }, { @@ -930,7 +935,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 1 } }, { @@ -952,7 +958,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 2 } }, { @@ -974,7 +981,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 2 } }, { @@ -996,7 +1004,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 1 } }, { @@ -1012,12 +1021,15 @@ "name": "curious creatures", "type": "load", "power": 10000.0, - "phase": [1, 2], + "phase": [ + 1, + 2 + ], "cum_power": 10000.0, "voltage": 107.3, "vdrop_percent": 53.35, "distro": { - "in": "1P 16.0A", + "in": "3P 32.0A", "out": {} } } @@ -1041,7 +1053,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 2 } }, { @@ -1063,7 +1076,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 3 } }, { @@ -1085,7 +1099,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 3 } }, { @@ -1127,7 +1142,7 @@ "voltage": 145.2, "vdrop_percent": 36.87, "distro": { - "in": "1P 16.0A", + "in": "3P 63.0A", "out": {} } } @@ -1151,7 +1166,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 1 } }, { @@ -1173,7 +1189,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 3 } }, { @@ -1195,10 +1212,10 @@ "distro": { "in": "3P 63.0A", "out": { - "3P 32.0A": 1, - "1P 16.0A": 1 + "3P 32.0A": 2 } - } + }, + "phase": 1 } }, { @@ -1220,8 +1237,8 @@ "distro": { "in": "3P 125A", "out": { - "3P 32.0A": 1, - "1P 16.0A": 3 + "3P 125.0A": 2, + "1P 16.0A": 2 } } } @@ -1243,7 +1260,7 @@ "voltage": 147.0, "vdrop_percent": 36.09, "distro": { - "in": "1P 16.0A", + "in": "3P 125.0A", "out": { "1P 16.0A": 3, "3P 63.0A": 1 @@ -1268,11 +1285,13 @@ "voltage": 185.7, "vdrop_percent": 19.26, "distro": { - "in": "3P 32.0A", + "in": "3P 125.0A", "out": { - "1P 16.0A": 3 + "3P 63.0A": 1, + "1P 16.0A": 2 } - } + }, + "phase": 3 } }, { @@ -1296,7 +1315,8 @@ "out": { "1P 16.0A": 1 } - } + }, + "phase": 1 } }, { @@ -1320,7 +1340,8 @@ "out": { "1P 16.0A": 7 } - } + }, + "phase": 2 } }, { @@ -1389,7 +1410,8 @@ "distro": { "in": "1P 16.0A", "out": {} - } + }, + "phase": 1 } }, { diff --git a/tests/test_pp_interop_phases.py b/tests/test_pp_interop_phases.py new file mode 100644 index 0000000..902726a --- /dev/null +++ b/tests/test_pp_interop_phases.py @@ -0,0 +1,304 @@ +"""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, + 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 capacity cut-off is 16 A x 230 V x 0.9, derived not hard-coded.""" + assert SINGLE_PHASE_CAPACITY_W == pytest.approx(16 * 230 * 0.9) + + +def test_candidates_include_only_eligible_loads(): + """Generator, zero-power, pre-phased and over-capacity loads are excluded.""" + 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)] + assert names == ["small"] + + +def test_candidates_return_usage_adjusted_watts_in_grid_order(): + """Each pair carries effective (usage x nameplate) watts, in grid order.""" + 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(): + """A load near the cut-off is a candidate at 0.5x but not at 1.0x.""" + # 5 kW nameplate: 2.5 kW effective at 0.5x (under ~3.3 kW cap), 5 kW at 1.0x (over). + 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(): + """Balanced loads spread /3; int phase lands on one leg; list phase splits.""" + 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] + + +# --- build_assignment -------------------------------------------------------- + + +def test_build_assignment_sums_seed_and_placements(): + """Leg totals are the fixed seed plus the candidate placements.""" + 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(): + """A level split is 0 %; a grid with no load is defined as 0 % too.""" + 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 100 x std / mean of the three leg totals.""" + 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 is a KeyError, not a silent skip.""" + 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(): + """Candidates get L1, L2, L3, L1, ... following grid order exactly.""" + 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(): + """Same grid in, same assignment out — no hidden state.""" + grid = make_grid([(n, 1500.0, None) for n in ("a", "b", "c")]) + assert assign_round_robin(grid).phases == assign_round_robin(grid).phases + + +def test_round_robin_usage_factor_changes_totals_not_legs(): + """The factor rescales leg totals but never moves a load to another leg.""" + 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(): + """A grid whose loads are all pre-phased yields an empty assignment.""" + grid = make_grid([("a", 1000.0, 1), ("b", 1000.0, 2)]) + assert assign_round_robin(grid).phases == {} + + +# --- assign_greedy ----------------------------------------------------------- + + +def test_greedy_places_heaviest_first_onto_lightest_leg(): + """Known layout: 3 kW + three 1 kW loads, all at usage 1.0. + + 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) + """ + 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(): + """The whole point of Strategy D: lower imbalance than Strategy B.""" + # 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, not just its candidates. + + A pre-assigned 4 kW load already sits on L1, so greedy should + steer its candidates onto L2/L3 to compensate. + """ + 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(): + """Equal-load ties break to the lowest leg index, every run.""" + grid = make_grid([(n, 1000.0, None) for n in ("a", "b", "c")]) + first = assign_greedy(grid) + second = assign_greedy(grid) + assert first.phases == second.phases == {"a": 1, "b": 2, "c": 3} + + +# --- apply_assignment -------------------------------------------------------- + + +def test_apply_assignment_mutates_candidates_only(): + """Candidate phases are written; generator and pre-phased loads untouched.""" + 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 assignment twice leaves the grid unchanged.""" + grid = make_grid([(n, 1000.0, None) for n in ("a", "b", "c")]) + assignment = assign_round_robin(grid) + 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(): + """An assignment from a different grid fails loudly rather than silently.""" + 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_default_usage_factor_is_half(): + """Doc 10's starting assumption: festival loads at 0.5x nameplate.""" + assert DEFAULT_USAGE_FACTOR == 0.5 + + +# --- 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. + + `2026-05-14_martin.geojson` is the unmodified field export — every + load arrives unphased, so it exercises the full candidate path. + """ + grid = PowerGrid.from_geojson(FIXTURES / "2026-05-14_martin.geojson") + + rr = assign_round_robin(grid) + greedy = assign_greedy(grid) + + # 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 From 8eb449eb7e1df51b46270193b4799fa26df603fa Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Thu, 14 May 2026 22:34:30 +0200 Subject: [PATCH 04/19] docs(research): add greedy strategy + B/D comparison to doc 10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Strategy D (greedy least-loaded leg) alongside the existing three, documents the shared usage-factor assumption, and records the B-vs-D comparison on the 2026 fixture. Headline finding: phase balancing and voltage-drop relief are decoupled — greedy cuts leg imbalance 9.5%->0.6% but the worst node's drop is unchanged. Marks the phase-synthesis work done (pp_interop/phases) and renumbers the future findings doc to 12 (doc 11 is now the field-export walkthrough). Co-Authored-By: Claude Opus 4.7 --- .../10_three_phase_asymmetric_modelling.md | 102 +++++++++++++++--- research/pandapower/README.md | 8 +- 2 files changed, 94 insertions(+), 16 deletions(-) diff --git a/research/pandapower/10_three_phase_asymmetric_modelling.md b/research/pandapower/10_three_phase_asymmetric_modelling.md index 1cddf3b..7315db8 100644 --- a/research/pandapower/10_three_phase_asymmetric_modelling.md +++ b/research/pandapower/10_three_phase_asymmetric_modelling.md @@ -181,9 +181,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 +225,70 @@ 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.7 % (`jamhouse`) | +| D greedy | 14.67 / 14.83 / 14.63 | **0.6 %** | 13.7 % (`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 is *identical*.** Leg balance + is a property of the *generator* totals; a node's voltage drop is + a property of the *radial path* feeding it — the cumulative + current on the specific cables between it and the source. + Re-balancing the legs at the generator does not re-route which + loads sit on a given branch, so the heavily-loaded trunk cables a + far node sits behind carry the same current either way. **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). + +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 +296,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 @@ -280,12 +355,12 @@ that calls `pp.runpp_3ph` instead of `runpp`. | Layer | Lines | |---|---:| | Add phase to `analyze_input.geojson` (already done) | 0 | -| Round-robin phase synthesis script for `input_nodes.geojson` | ~5 | +| Phase synthesis strategies B + D (`pp_interop/phases`, **done**) | 0 | | 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 | +| Findings doc (`12_runpp_3ph_findings.md`) | ~200 | The biggest unknown is whether `runpp_3ph` converges cleanly on the 2025-scale fixture (51 nodes) given its documented sensitivity, @@ -338,13 +413,12 @@ only way to know is to measure with `runpp_3ph`. ## Where it slots in -If pursued, this work would land as: +If pursued, the remaining work would land as: -- A new branch off `feat/pandapower-comparison-tests` (the current - PR stack head): `feat/pandapower-runpp-3ph`. - A new module file `pp_interop/_powerflow_3ph.py` mirroring - `_powerflow.py`'s shape. -- A new findings doc `11_runpp_3ph_findings.md` capturing the + `_powerflow.py`'s shape. (The phase-synthesis half of the work — + `pp_interop/phases` — is already done.) +- A new findings doc `12_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 diff --git a/research/pandapower/README.md b/research/pandapower/README.md index 4d67f4f..1ac268e 100644 --- a/research/pandapower/README.md +++ b/research/pandapower/README.md @@ -47,8 +47,12 @@ 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 + leaves the worst node's drop unchanged. 11. [`11_field_export_fixture_walkthrough.md`](11_field_export_fixture_walkthrough.md) — how to plug Martin's `2026-05-14_martin.geojson` field export into the pandapower code. Two gaps: a trivial schema mismatch (export From 3a9038f3d068acaa59df4307a157aef5e4d36dd5 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Thu, 14 May 2026 22:47:42 +0200 Subject: [PATCH 05/19] feat: add asymmetric three-phase power flow (runpp_3ph) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New _powerflow_3ph.py runs pandapower's unbalanced solve — the path that sees per-leg voltage drop and neutral-conductor current, neither of which a balanced runpp can show. to_pandapower_3ph augments the balanced net (zero-sequence line and source params, asymmetric loads swapped in for phased loads) rather than branching inside _conversion.py, leaving the balanced path untouched. compute_power_flow_3ph returns per-leg bus voltages, per-leg and neutral line currents, and the voltage unbalance factor. Zero-sequence and source-earthing defaults added to config.py. Updates doc 10 to mark the code layers done. Co-Authored-By: Claude Opus 4.7 --- .../10_three_phase_asymmetric_modelling.md | 72 +++-- src/nopywer/pp_interop/__init__.py | 8 + src/nopywer/pp_interop/_powerflow_3ph.py | 306 ++++++++++++++++++ src/nopywer/pp_interop/config.py | 29 ++ tests/test_pp_interop_powerflow_3ph.py | 189 +++++++++++ 5 files changed, 579 insertions(+), 25 deletions(-) create mode 100644 src/nopywer/pp_interop/_powerflow_3ph.py create mode 100644 tests/test_pp_interop_powerflow_3ph.py diff --git a/research/pandapower/10_three_phase_asymmetric_modelling.md b/research/pandapower/10_three_phase_asymmetric_modelling.md index 7315db8..6c96b95 100644 --- a/research/pandapower/10_three_phase_asymmetric_modelling.md +++ b/research/pandapower/10_three_phase_asymmetric_modelling.md @@ -265,24 +265,37 @@ Running both on the 2026 modified fixture (doc 11) at 0.5× usage — | Strategy | L1 / L2 / L3 (kW) | Leg balance | Worst tree-walk vdrop | |---|---|---:|---:| -| B round-robin | 16.17 / 15.13 / 12.83 | 9.5 % | 13.7 % (`jamhouse`) | -| D greedy | 14.67 / 14.83 / 14.63 | **0.6 %** | 13.7 % (`jamhouse`) | +| 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 is *identical*.** Leg balance - is a property of the *generator* totals; a node's voltage drop is - a property of the *radial path* feeding it — the cumulative - current on the specific cables between it and the source. - Re-balancing the legs at the generator does not re-route which - loads sit on a given branch, so the heavily-loaded trunk cables a - far node sits behind carry the same current either way. **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). +- **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 @@ -352,15 +365,19 @@ that calls `pp.runpp_3ph` instead of `runpp`. ### Scope summary -| Layer | Lines | -|---|---:| -| Add phase to `analyze_input.geojson` (already done) | 0 | -| Phase synthesis strategies B + D (`pp_interop/phases`, **done**) | 0 | -| 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 (`12_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`) | not started | + +The conversion landed as `to_pandapower_3ph` — it augments the +balanced `to_pandapower` net (zero-sequence line/source params, +asymmetric loads swapped in for phased loads) rather than branching +inside `_conversion.py`, keeping the balanced path untouched. The biggest unknown is whether `runpp_3ph` converges cleanly on the 2025-scale fixture (51 nodes) given its documented sensitivity, @@ -413,14 +430,19 @@ only way to know is to measure with `runpp_3ph`. ## Where it slots in -If pursued, the remaining work would land as: +The code is done — `pp_interop/phases` (phase synthesis), +`config.py` (zero-sequence defaults), and `pp_interop/_powerflow_3ph.py` +(`to_pandapower_3ph` + `compute_power_flow_3ph`), all with tests. +What remains: -- A new module file `pp_interop/_powerflow_3ph.py` mirroring - `_powerflow.py`'s shape. (The phase-synthesis half of the work — - `pp_interop/phases` — is already done.) - A new findings doc `12_runpp_3ph_findings.md` capturing the numbers — same register as [`07_optimiser_validation_findings.md`](./07_optimiser_validation_findings.md). + Early signal: `runpp_3ph` converges on the 2026 modified fixture + at 0.5× usage (worst leg ~20 % drop, worst neutral ~20 A) but + *not* at full nameplate — asymmetric loading tips a grid past + collapse on its heaviest legs even where the balanced solve still + finds a fixed point. - Extension of `scripts/sensitivity_sweep.py` to add a fourth sweep over the new zero-sequence ratio defaults. diff --git a/src/nopywer/pp_interop/__init__.py b/src/nopywer/pp_interop/__init__.py index 722a711..27a74c7 100644 --- a/src/nopywer/pp_interop/__init__.py +++ b/src/nopywer/pp_interop/__init__.py @@ -19,15 +19,23 @@ compare_with_tree_walk, compute_power_flow, ) +from ._powerflow_3ph import ( + PowerFlow3phResults, + compute_power_flow_3ph, + to_pandapower_3ph, +) from ._shortcircuit import compute_short_circuit __all__ = [ "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/_powerflow_3ph.py b/src/nopywer/pp_interop/_powerflow_3ph.py new file mode 100644 index 0000000..47002bc --- /dev/null +++ b/src/nopywer/pp_interop/_powerflow_3ph.py @@ -0,0 +1,306 @@ +"""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 on the balanced `to_pandapower` and +augments it rather than duplicating the conversion: it bolts the +zero-sequence params onto the lines and ext_grid, and swaps each +explicitly-phased load's balanced `pp.load` for a `pp.asymmetric_load`. + +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 math +from dataclasses import dataclass + +from ..constants import PF, V0 +from . import config +from ._conversion import PandapowerGrid, _import_pandapower, to_pandapower + + +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 (`None`, the `"U"` / `"Y"` sub-grid markers, an + empty or malformed list) — balanced: an even third on each + leg. + + 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: PandapowerGrid | object, *, load_pf: float = PF, **to_pp_kwargs: object +) -> PandapowerGrid: + """Convert a `PowerGrid` for asymmetric flow. + + Starts from the balanced `to_pandapower` net and augments it for + `runpp_3ph`: + + 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. **Asymmetric loads** — every load with an explicit `phase` + (single int or list) has its balanced `pp.load` dropped and + replaced with a `pp.asymmetric_load` carrying the per-leg + split from `_phase_split`. Truly balanced loads (`phase` + `None`) keep their `pp.load` — `runpp_3ph` already treats + that as an even three-phase draw. + + The returned `PandapowerGrid.load_idx` is pruned to only the + loads that remain balanced `pp.load`s; the asymmetric ones live + in `net.asymmetric_load` and are keyed by name there. + + 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 per-leg reactive + power for the asymmetric loads. Defaults to the global + `PF`. + **to_pp_kwargs: forwarded verbatim to `to_pandapower` + (generator ratings, `x_ohm_per_km`, etc.). + + Returns: + A `PandapowerGrid` whose `net` is ready for + `compute_power_flow_3ph`. + + Raises: + ValueError: propagated from `to_pandapower` if the grid has + no cables or a cable references an unknown node. + ImportError: if pandapower is not installed. + """ + pp, _ = _import_pandapower() + pp_grid = to_pandapower(grid, load_pf=load_pf, **to_pp_kwargs) + net = pp_grid.net + + # 1. 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 + + # 2. source vector group / earthing + net.ext_grid["r0x0_max"] = config.SOURCE_R0X0_MAX + net.ext_grid["x0x_max"] = config.SOURCE_X0X_MAX + + # 3. swap explicitly-phased loads for asymmetric loads + 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_l1, p_l2, p_l3 = _phase_split(node.phase, node.power_watts) + if p_l1 == p_l2 == p_l3: + continue # balanced — to_pandapower's pp.load is already correct + + net.load.drop(index=pp_grid.load_idx.pop(name), inplace=True) + pp.create_asymmetric_load( + net, + bus=pp_grid.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 pp_grid + + +@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: PandapowerGrid) -> 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`. + + The net must have been built by `to_pandapower_3ph` — a plain + `to_pandapower` net lacks the zero-sequence parameters and + `runpp_3ph` will not solve it. + + 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 2c978ec..eff2031 100644 --- a/src/nopywer/pp_interop/config.py +++ b/src/nopywer/pp_interop/config.py @@ -99,3 +99,32 @@ # 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. For 4-core LV flex with a full-section neutral the +# neutral-and-earth return loop has roughly 3-5x the resistance and +# reactance of a single phase conductor; 4x is the mid-range default. +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 diff --git a/tests/test_pp_interop_powerflow_3ph.py b/tests/test_pp_interop_powerflow_3ph.py new file mode 100644 index 0000000..026013b --- /dev/null +++ b/tests/test_pp_interop_powerflow_3ph.py @@ -0,0 +1,189 @@ +"""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` augments the balanced net correctly — + zero-sequence params on lines and ext_grid, asymmetric loads + swapped in for phased loads; + - `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 `phase` maps to a per-leg tuple summing to the load.""" + result = _phase_split(phase, 9000.0) + assert result == expected + assert sum(result) == pytest.approx(9000.0) + + +# --- to_pandapower_3ph ------------------------------------------------------- + + +def test_to_3ph_swaps_phased_loads_for_asymmetric(): + """Phased loads become asymmetric_loads; balanced loads stay pp.load.""" + 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 + # load_idx is pruned to the loads that stayed balanced + assert set(pp_grid.load_idx) == {"balanced"} + + +def test_to_3ph_sets_zero_sequence_line_params(): + """r0/x0 are the positive-sequence values times the config ratios.""" + 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.""" + 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 needs no asymmetric_load table entries.""" + 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 + + +# --- 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.""" + 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: its own leg sags, only it draws current, and + the whole current returns on the neutral. + + Physics check — for a purely single-phase draw the return current + has nowhere to go but the neutral, so the cable's neutral current + must match its loaded-phase current. 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 — which is the whole reason a + `runpp_3ph` solve exists. + """ + 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(): + """worst_phase_vdrop / worst_neutral_current pick out the right extremes.""" + # 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 helpers degrade gracefully rather than raising on empty results.""" + 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 From 765b137bb8111cd0d3aac786341943499df17040 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Thu, 14 May 2026 22:55:24 +0200 Subject: [PATCH 06/19] test: expand docstrings on phase + 3ph power flow tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every test now documents what it asserts and why it matters — the bug class it guards or the physics/contract it pins. Flags the two wiring-regression guards as such (not physics checks), and records why the single-phase test does not assert L2 == L3. Co-Authored-By: Claude Opus 4.7 --- tests/test_pp_interop_phases.py | 260 ++++++++++++++++++++++--- tests/test_pp_interop_powerflow_3ph.py | 153 +++++++++++++-- 2 files changed, 368 insertions(+), 45 deletions(-) diff --git a/tests/test_pp_interop_phases.py b/tests/test_pp_interop_phases.py index 902726a..930973b 100644 --- a/tests/test_pp_interop_phases.py +++ b/tests/test_pp_interop_phases.py @@ -55,12 +55,31 @@ def make_grid(loads: list[tuple[str, float, object]], gen_power: float = 0.0) -> def test_single_phase_capacity_constant(): - """The capacity cut-off is 16 A x 230 V x 0.9, derived not hard-coded.""" + """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(): - """Generator, zero-power, pre-phased and over-capacity loads are excluded.""" + """`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 @@ -77,14 +96,34 @@ def test_candidates_include_only_eligible_loads(): def test_candidates_return_usage_adjusted_watts_in_grid_order(): - """Each pair carries effective (usage x nameplate) 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(): - """A load near the cut-off is a candidate at 0.5x but not at 1.0x.""" - # 5 kW nameplate: 2.5 kW effective at 0.5x (under ~3.3 kW cap), 5 kW at 1.0x (over). + """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) == [] @@ -94,7 +133,19 @@ def test_candidates_capacity_cutoff_moves_with_usage_factor(): def test_fixed_leg_seed_spreads_balanced_and_lands_phased(): - """Balanced loads spread /3; int phase lands on one leg; list phase splits.""" + """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 @@ -112,7 +163,17 @@ def test_fixed_leg_seed_spreads_balanced_and_lands_phased(): def test_build_assignment_sums_seed_and_placements(): - """Leg totals are the fixed seed plus the candidate 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) @@ -121,7 +182,17 @@ def test_build_assignment_sums_seed_and_placements(): def test_build_assignment_balance_pct_zero_when_level_and_when_empty(): - """A level split is 0 %; a grid with no load is defined as 0 % too.""" + """`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 @@ -132,7 +203,18 @@ def test_build_assignment_balance_pct_zero_when_level_and_when_empty(): def test_build_assignment_balance_pct_matches_std_over_mean(): - """balance_pct is 100 x std / mean of the three leg totals.""" + """`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) @@ -142,7 +224,16 @@ def test_build_assignment_balance_pct_matches_std_over_mean(): def test_build_assignment_raises_on_unknown_node(): - """Naming a node that is not on the grid is a KeyError, not a silent skip.""" + """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) @@ -151,20 +242,45 @@ def test_build_assignment_raises_on_unknown_node(): def test_round_robin_cycles_legs_in_grid_order(): - """Candidates get L1, L2, L3, L1, ... following grid order exactly.""" + """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(): - """Same grid in, same assignment out — no hidden state.""" + """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).phases == assign_round_robin(grid).phases def test_round_robin_usage_factor_changes_totals_not_legs(): - """The factor rescales leg totals but never moves a load to another leg.""" + """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) @@ -173,7 +289,16 @@ def test_round_robin_usage_factor_changes_totals_not_legs(): def test_round_robin_empty_when_no_candidates(): - """A grid whose loads are all pre-phased yields an empty assignment.""" + """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).phases == {} @@ -182,13 +307,20 @@ def test_round_robin_empty_when_no_candidates(): def test_greedy_places_heaviest_first_onto_lightest_leg(): - """Known layout: 3 kW + three 1 kW loads, all at usage 1.0. + """Greedy follows the longest-processing-time rule, step by step. - Sorted heaviest-first: a(3000), then b, c, d (1000 each). + 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)] @@ -199,7 +331,17 @@ def test_greedy_places_heaviest_first_onto_lightest_leg(): def test_greedy_beats_round_robin_on_uneven_load_sizes(): - """The whole point of Strategy D: lower imbalance than Strategy B.""" + """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( @@ -211,10 +353,17 @@ def test_greedy_beats_round_robin_on_uneven_load_sizes(): def test_greedy_balances_around_the_fixed_seed(): - """Greedy levels the *whole* grid, not just its candidates. + """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). - A pre-assigned 4 kW load already sits on L1, so greedy should - steer its candidates onto L2/L3 to compensate. + 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( [ @@ -230,7 +379,18 @@ def test_greedy_balances_around_the_fixed_seed(): def test_greedy_is_reproducible_with_deterministic_tie_break(): - """Equal-load ties break to the lowest leg index, every run.""" + """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) second = assign_greedy(grid) @@ -241,7 +401,17 @@ def test_greedy_is_reproducible_with_deterministic_tie_break(): def test_apply_assignment_mutates_candidates_only(): - """Candidate phases are written; generator and pre-phased loads untouched.""" + """`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, @@ -254,7 +424,16 @@ def test_apply_assignment_mutates_candidates_only(): def test_apply_assignment_is_idempotent(): - """Applying the same assignment twice leaves the grid unchanged.""" + """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) apply_assignment(grid, assignment) @@ -264,7 +443,16 @@ def test_apply_assignment_is_idempotent(): def test_apply_assignment_raises_on_unknown_node(): - """An assignment from a different grid fails loudly rather than silently.""" + """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 @@ -277,7 +465,15 @@ def test_apply_assignment_raises_on_unknown_node(): def test_default_usage_factor_is_half(): - """Doc 10's starting assumption: festival loads at 0.5x nameplate.""" + """The default usage factor is 0.5x. + + What: `DEFAULT_USAGE_FACTOR == 0.5`. + + Why it matters: 0.5x is doc 10's stated starting assumption — + festival loads rarely draw nameplate together. Pinning it here + means a change to that assumption has to be deliberate (and shows + up as a failing test) rather than drifting in unnoticed. + """ assert DEFAULT_USAGE_FACTOR == 0.5 @@ -287,8 +483,18 @@ def test_default_usage_factor_is_half(): def test_strategies_run_on_field_export_and_greedy_balances_better(): """Both strategies handle the real 2026 export; greedy wins on balance. - `2026-05-14_martin.geojson` is the unmodified field export — every - load arrives unphased, so it exercises the full candidate path. + 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") diff --git a/tests/test_pp_interop_powerflow_3ph.py b/tests/test_pp_interop_powerflow_3ph.py index 026013b..a7b7c8e 100644 --- a/tests/test_pp_interop_powerflow_3ph.py +++ b/tests/test_pp_interop_powerflow_3ph.py @@ -60,7 +60,22 @@ def _grid(loads: list[tuple[str, float, object]]) -> PowerGrid: ], ) def test_phase_split(phase, expected): - """Every form of `phase` maps to a per-leg tuple summing to the load.""" + """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) @@ -70,7 +85,21 @@ def test_phase_split(phase, expected): def test_to_3ph_swaps_phased_loads_for_asymmetric(): - """Phased loads become asymmetric_loads; balanced loads stay pp.load.""" + """A phased load becomes an asymmetric_load; a balanced load stays a pp.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 + `load_idx` is pruned down to just the load that stayed balanced. + + Why it matters: `to_pandapower_3ph` builds on the balanced + converter and *mutates* its output — dropping the balanced + `pp.load` rows for phased loads and re-adding them as asymmetric. + Get the swap wrong and a phased load is either counted twice (a + leftover `pp.load` plus the asymmetric one) or lost entirely. The + `load_idx` check guards the bookkeeping: it must end up describing + only the loads that remain plain `pp.load`s, or downstream + write-back keyed off it would touch the wrong rows. + """ grid = _grid([("balanced", 3000.0, None), ("on_l1", 3000.0, 1)]) pp_grid = to_pandapower_3ph(grid) @@ -81,7 +110,21 @@ def test_to_3ph_swaps_phased_loads_for_asymmetric(): def test_to_3ph_sets_zero_sequence_line_params(): - """r0/x0 are the positive-sequence values times the config ratios.""" + """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] @@ -91,14 +134,37 @@ def test_to_3ph_sets_zero_sequence_line_params(): def test_to_3ph_sets_source_vector_group(): - """The ext_grid carries the genset's zero-sequence return ratios.""" + """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 needs no asymmetric_load table entries.""" + """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 @@ -109,7 +175,21 @@ def test_to_3ph_all_balanced_grid_has_no_asymmetric_loads(): def test_balanced_load_sags_all_legs_equally_with_no_neutral_current(): - """A balanced load is the symmetric case: equal legs, ~zero neutral.""" + """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)) @@ -124,16 +204,29 @@ def test_balanced_load_sags_all_legs_equally_with_no_neutral_current(): def test_single_phase_load_sags_its_own_leg_and_loads_the_neutral(): - """A single-phase load: its own leg sags, only it draws current, and - the whole current returns on the neutral. - - Physics check — for a purely single-phase draw the return current - has nowhere to go but the neutral, so the cable's neutral current - must match its loaded-phase current. 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 — which is the whole reason a - `runpp_3ph` solve exists. + """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)) @@ -154,7 +247,21 @@ def test_single_phase_load_sags_its_own_leg_and_loads_the_neutral(): def test_results_helpers_find_worst_leg_and_neutral(): - """worst_phase_vdrop / worst_neutral_current pick out the right extremes.""" + """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)) @@ -175,7 +282,17 @@ def test_results_helpers_find_worst_leg_and_neutral(): def test_results_helpers_return_none_on_empty(): - """The helpers degrade gracefully rather than raising on empty results.""" + """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={}, From 4e10e9d518923c1a245516197bc6834c9542c6ae Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Thu, 14 May 2026 22:56:52 +0200 Subject: [PATCH 07/19] docs: add cable cost research --- research/pandapower/13_cable_costs.md | 179 ++++++++++++++++++++++++++ research/pandapower/README.md | 5 + 2 files changed, 184 insertions(+) create mode 100644 research/pandapower/13_cable_costs.md 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 1ac268e..99cf9b5 100644 --- a/research/pandapower/README.md +++ b/research/pandapower/README.md @@ -60,6 +60,11 @@ Date of research: April 2026. Pandapower latest at time of writing: **3.4.0** `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. [`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 From 0e5d71153ac86f31e6706f09e9c0d4773c22c3b5 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Thu, 14 May 2026 23:11:44 +0200 Subject: [PATCH 08/19] docs(research): add 10.1 three-phase theory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The physics behind doc 10: why a balanced solve can collapse three phases to one, symmetrical components, why the zero sequence is the neutral return, and a full walkthrough of why a single-phase load unbalances the two legs it does not touch — including a verification experiment showing the L2/L3 split has a precise physical on/off switch (Z0 = Z1) and is not a solver artefact. Co-Authored-By: Claude Opus 4.7 --- .../pandapower/10.1_three_phase_theory.md | 247 ++++++++++++++++++ research/pandapower/README.md | 8 +- 2 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 research/pandapower/10.1_three_phase_theory.md 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..7304051 --- /dev/null +++ b/research/pandapower/10.1_three_phase_theory.md @@ -0,0 +1,247 @@ +# 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. + +## 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 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/README.md b/research/pandapower/README.md index 99cf9b5..658e489 100644 --- a/research/pandapower/README.md +++ b/research/pandapower/README.md @@ -52,7 +52,13 @@ Date of research: April 2026. Pandapower latest at time of writing: **3.4.0** 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 - leaves the worst node's drop unchanged. + 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 Martin's `2026-05-14_martin.geojson` field export into the pandapower code. Two gaps: a trivial schema mismatch (export From 70f7ebd00a928d3531e30fc13e981e071d307408 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Fri, 15 May 2026 09:03:34 +0200 Subject: [PATCH 09/19] docs(research): add 12 runpp_3ph findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First run of the asymmetric solver on the 2026 modified fixture with both phase strategies and across load scales. Headlines: balanced runpp under-states the worst drop ~2.2x at 0.5x usage and *converges* at full load where runpp_3ph has no AC operating point; phase strategy buys convergence margin but does not bring drops near spec; one 2.5mm² cable carries 20A on its neutral under a plausible phase plan -- the cable-spec question doc 10 raised, with a real number against it for the first time. Updates doc 10 status and the README index. Co-Authored-By: Claude Opus 4.7 --- .../10_three_phase_asymmetric_modelling.md | 27 +-- research/pandapower/12_runpp_3ph_findings.md | 211 ++++++++++++++++++ research/pandapower/README.md | 11 +- 3 files changed, 235 insertions(+), 14 deletions(-) create mode 100644 research/pandapower/12_runpp_3ph_findings.md diff --git a/research/pandapower/10_three_phase_asymmetric_modelling.md b/research/pandapower/10_three_phase_asymmetric_modelling.md index 6c96b95..860b545 100644 --- a/research/pandapower/10_three_phase_asymmetric_modelling.md +++ b/research/pandapower/10_three_phase_asymmetric_modelling.md @@ -372,7 +372,7 @@ that calls `pp.runpp_3ph` instead of `runpp`. | 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`) | not started | +| Findings doc ([`12_runpp_3ph_findings.md`](./12_runpp_3ph_findings.md)) | **done** | The conversion landed as `to_pandapower_3ph` — it augments the balanced `to_pandapower` net (zero-sequence line/source params, @@ -430,21 +430,22 @@ only way to know is to measure with `runpp_3ph`. ## Where it slots in -The code is done — `pp_interop/phases` (phase synthesis), -`config.py` (zero-sequence defaults), and `pp_interop/_powerflow_3ph.py` -(`to_pandapower_3ph` + `compute_power_flow_3ph`), all with tests. +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 findings doc `12_runpp_3ph_findings.md` capturing the - numbers — same register as - [`07_optimiser_validation_findings.md`](./07_optimiser_validation_findings.md). - Early signal: `runpp_3ph` converges on the 2026 modified fixture - at 0.5× usage (worst leg ~20 % drop, worst neutral ~20 A) but - *not* at full nameplate — asymmetric loading tips a grid past - collapse on its heaviest legs even where the balanced solve still - finds a fixed point. - 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/12_runpp_3ph_findings.md b/research/pandapower/12_runpp_3ph_findings.md new file mode 100644 index 0000000..b81f823 --- /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` — + Martin's 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 Martin's + 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/README.md b/research/pandapower/README.md index 658e489..7f8bb87 100644 --- a/research/pandapower/README.md +++ b/research/pandapower/README.md @@ -66,7 +66,16 @@ Date of research: April 2026. Pandapower latest at time of writing: **3.4.0** `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. [`13_cable_costs.md`](13_cable_costs.md) — EU web research on finished +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 From 29588d21dd012e417c519efa62dfddff82bb97fe Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 17 May 2026 21:05:21 +0200 Subject: [PATCH 10/19] refactor(pp_interop): make usage_factor explicit and document modelling assumptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Require `usage_factor` (keyword-only, no default) on every public function in `pp_interop.phases` so the choice is always visible at the call site. - Move `DEFAULT_USAGE_FACTOR` from `phases/_common.py` to `pp_interop/config.py` (re-exported via `phases`) so all modelling tunables live in one place. - Expand `R0_OVER_R1` / `X0_OVER_X1` config comments to spell out the cable build (3P + full-N + full-PE) and TN-S earthing assumed by the 4.0 default, with an override matrix for other topologies. - Add a new "Picking R0/R1 and X0/X1 — what the ratios really mean" section to research doc 10.1 with the derivation, override table, and sources (IEC 60909-2, Roeper). - Document the "multi-phase loads self-balance evenly" assumption (e.g. phase=[1, 2] splits 50/50) in research docs 10 and 11, flagging when it would be wrong and where the fix would live. - Add a README at `src/nopywer/pp_interop/` covering the code flow, inputs, outputs, and runnable examples for the balanced and asymmetric solver paths. Co-Authored-By: Claude Opus 4.7 --- .../pandapower/10.1_three_phase_theory.md | 99 +++++++++- .../10_three_phase_asymmetric_modelling.md | 31 ++- .../11_field_export_fixture_walkthrough.md | 16 +- src/nopywer/pp_interop/README.md | 176 ++++++++++++++++++ src/nopywer/pp_interop/config.py | 65 ++++++- src/nopywer/pp_interop/phases/__init__.py | 2 +- src/nopywer/pp_interop/phases/_common.py | 18 +- src/nopywer/pp_interop/phases/_greedy.py | 22 ++- src/nopywer/pp_interop/phases/_round_robin.py | 23 ++- tests/test_pp_interop_parity.py | 2 +- tests/test_pp_interop_phases.py | 39 ++-- 11 files changed, 437 insertions(+), 56 deletions(-) create mode 100644 src/nopywer/pp_interop/README.md diff --git a/research/pandapower/10.1_three_phase_theory.md b/research/pandapower/10.1_three_phase_theory.md index 7304051..960f533 100644 --- a/research/pandapower/10.1_three_phase_theory.md +++ b/research/pandapower/10.1_three_phase_theory.md @@ -88,6 +88,98 @@ 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 @@ -242,6 +334,11 @@ physically cannot run**. - 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. + 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 6136f0f..d47e235 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 @@ -392,9 +417,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 diff --git a/research/pandapower/11_field_export_fixture_walkthrough.md b/research/pandapower/11_field_export_fixture_walkthrough.md index 74d3ca8..36ee3e9 100644 --- a/research/pandapower/11_field_export_fixture_walkthrough.md +++ b/research/pandapower/11_field_export_fixture_walkthrough.md @@ -119,8 +119,7 @@ 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 for -Martin and Vincent: +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 @@ -238,6 +237,19 @@ rating, with the unused L3 core idle. The lesson for the doc 10 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 diff --git a/src/nopywer/pp_interop/README.md b/src/nopywer/pp_interop/README.md new file mode 100644 index 0000000..4324af1 --- /dev/null +++ b/src/nopywer/pp_interop/README.md @@ -0,0 +1,176 @@ +# `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. The asymmetric solver +needs that to be populated; the balanced solver ignores it. + +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) │ + │ (balanced) │ │ (asymmetric — augments │ + │ │ │ the balanced net with │ + │ │ │ zero-seq params + swaps │ + │ │ │ phased loads for │ + │ │ │ asymmetric_loads) │ + └──────────┬─────────────┘ └────────────────┬─────────────┘ + │ │ + ▼ ▼ + ┌────────────────────────┐ ┌────────────────────────────┐ + │ 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. 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; override via kwargs to `to_pandapower(...)`. | +| **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"]) +``` + +**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) +``` + +## Things to remember + +- `to_pandapower_3ph` **augments and mutates** the balanced net + produced by `to_pandapower` (pops phased loads out of `net.load`, + adds them to `net.asymmetric_load`). Don't reuse the same + `PandapowerGrid` for both balanced and asymmetric solves — convert + fresh each time. +- `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/config.py b/src/nopywer/pp_interop/config.py index 9f19089..608aec8 100644 --- a/src/nopywer/pp_interop/config.py +++ b/src/nopywer/pp_interop/config.py @@ -111,9 +111,52 @@ # engineering rule-of-thumb defaults. See research doc 10. # Zero-sequence cable R and X as a multiple of the positive-sequence -# value. For 4-core LV flex with a full-section neutral the -# neutral-and-earth return loop has roughly 3-5x the resistance and -# reactance of a single phase conductor; 4x is the mid-range default. +# 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 @@ -128,3 +171,19 @@ # 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 index 8483b4c..5f2558f 100644 --- a/src/nopywer/pp_interop/phases/__init__.py +++ b/src/nopywer/pp_interop/phases/__init__.py @@ -17,8 +17,8 @@ assumptions (usage factor, single-phase capacity) live in `_common`. """ +from ..config import DEFAULT_USAGE_FACTOR from ._common import ( - DEFAULT_USAGE_FACTOR, SINGLE_PHASE_CAPACITY_W, PhaseAssignment, apply_assignment, diff --git a/src/nopywer/pp_interop/phases/_common.py b/src/nopywer/pp_interop/phases/_common.py index eaecf18..9621391 100644 --- a/src/nopywer/pp_interop/phases/_common.py +++ b/src/nopywer/pp_interop/phases/_common.py @@ -43,11 +43,6 @@ from ...constants import PF, V0 from ...models import Cable16A, PowerGrid -# 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 a deliberately conservative starting assumption (see doc 10). -DEFAULT_USAGE_FACTOR = 0.5 - # 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 @@ -77,7 +72,7 @@ class PhaseAssignment: `name in assignment.phases` is the precise test for "this strategy moved this load". usage_factor: the nameplate fraction used for balancing (see - `DEFAULT_USAGE_FACTOR`). Recorded so a `PhaseAssignment` + `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 @@ -103,7 +98,7 @@ class PhaseAssignment: def single_phase_candidates( - grid: PowerGrid, usage_factor: float = DEFAULT_USAGE_FACTOR + grid: PowerGrid, *, usage_factor: float ) -> list[tuple[str, float]]: """Loads eligible for a single-leg assignment. @@ -127,8 +122,13 @@ def single_phase_candidates( Args: grid: the grid to inspect. Not mutated. - usage_factor: assumed fraction of nameplate power loads draw - together. Scales the effective power used for the + 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. diff --git a/src/nopywer/pp_interop/phases/_greedy.py b/src/nopywer/pp_interop/phases/_greedy.py index 1337941..8fa7036 100644 --- a/src/nopywer/pp_interop/phases/_greedy.py +++ b/src/nopywer/pp_interop/phases/_greedy.py @@ -31,7 +31,6 @@ from ...models import PowerGrid from ._common import ( - DEFAULT_USAGE_FACTOR, PhaseAssignment, _fixed_leg_seed, build_assignment, @@ -39,7 +38,7 @@ ) -def assign_greedy(grid: PowerGrid, usage_factor: float = DEFAULT_USAGE_FACTOR) -> PhaseAssignment: +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 @@ -65,13 +64,16 @@ def assign_greedy(grid: PowerGrid, usage_factor: float = DEFAULT_USAGE_FACTOR) - Args: grid: the grid to plan phases for. Not mutated — pass the result to `apply_assignment` to write it back. - usage_factor: assumed fraction of nameplate power loads draw - together. 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. + 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 @@ -79,7 +81,7 @@ def assign_greedy(grid: PowerGrid, usage_factor: float = DEFAULT_USAGE_FACTOR) - 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) + 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] = {} diff --git a/src/nopywer/pp_interop/phases/_round_robin.py b/src/nopywer/pp_interop/phases/_round_robin.py index 960d47b..750c5a4 100644 --- a/src/nopywer/pp_interop/phases/_round_robin.py +++ b/src/nopywer/pp_interop/phases/_round_robin.py @@ -26,16 +26,13 @@ from ...models import PowerGrid from ._common import ( - DEFAULT_USAGE_FACTOR, PhaseAssignment, build_assignment, single_phase_candidates, ) -def assign_round_robin( - grid: PowerGrid, usage_factor: float = DEFAULT_USAGE_FACTOR -) -> PhaseAssignment: +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 @@ -54,13 +51,15 @@ def assign_round_robin( Args: grid: the grid to plan phases for. Not mutated — pass the result to `apply_assignment` to write it back. - usage_factor: assumed fraction of nameplate power loads draw - together. 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). + usage_factor: **required**, keyword-only. Assumed fraction + of nameplate power loads draw together. No default — + see `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 @@ -69,6 +68,6 @@ def assign_round_robin( result describes just the fixed (balanced / pre-assigned) loads. """ - candidates = single_phase_candidates(grid, usage_factor) + 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/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 index 930973b..224ef7f 100644 --- a/tests/test_pp_interop_phases.py +++ b/tests/test_pp_interop_phases.py @@ -91,7 +91,7 @@ def test_candidates_include_only_eligible_loads(): ], gen_power=1000.0, ) - names = [name for name, _ in single_phase_candidates(grid)] + names = [name for name, _ in single_phase_candidates(grid, usage_factor=0.5)] assert names == ["small"] @@ -267,7 +267,10 @@ def test_round_robin_is_reproducible(): 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).phases == assign_round_robin(grid).phases + 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(): @@ -300,7 +303,7 @@ def test_round_robin_empty_when_no_candidates(): 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).phases == {} + assert assign_round_robin(grid, usage_factor=0.5).phases == {} # --- assign_greedy ----------------------------------------------------------- @@ -392,8 +395,8 @@ def test_greedy_is_reproducible_with_deterministic_tie_break(): corrosive to a committed fixture. """ grid = make_grid([(n, 1000.0, None) for n in ("a", "b", "c")]) - first = assign_greedy(grid) - second = assign_greedy(grid) + 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} @@ -435,7 +438,7 @@ def test_apply_assignment_is_idempotent(): 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) + 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) @@ -464,17 +467,25 @@ def test_apply_assignment_raises_on_unknown_node(): # --- defaults ---------------------------------------------------------------- -def test_default_usage_factor_is_half(): - """The default usage factor is 0.5x. +def test_reference_usage_factor_is_half(): + """The project-wide reference usage factor is 0.5x. - What: `DEFAULT_USAGE_FACTOR == 0.5`. + 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. Pinning it here - means a change to that assumption has to be deliberate (and shows - up as a failing test) rather than drifting in unnoticed. + 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 ------------------------------------ @@ -498,8 +509,8 @@ def test_strategies_run_on_field_export_and_greedy_balances_better(): """ grid = PowerGrid.from_geojson(FIXTURES / "2026-05-14_martin.geojson") - rr = assign_round_robin(grid) - greedy = assign_greedy(grid) + 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 From 754bf878eb1a3ae474ef98aa707cf499c9ca1bbe Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 17 May 2026 21:36:02 +0200 Subject: [PATCH 11/19] refactor(pp_interop): split converters cleanly, surface assumptions in docs - Refactor `to_pandapower_3ph` to build an independent net via a shared `_build_net_skeleton` helper, instead of mutating the balanced converter's output. The two converters now produce fully independent `pandapowerNet`s. - Introduce `Pandapower3phGrid` as a distinct return type for the asymmetric path, with separate `balanced_load_idx` and `asymmetric_load_idx` maps so the dual-table layout is encoded in the type rather than in a comment. `compute_power_flow_3ph` now takes `Pandapower3phGrid` specifically. - Add `phase_geojson` one-call wrapper for the common "load, plan, apply, write" script/CLI workflow. - Emit `PowerNode.phase` from `PowerNode.to_geojson` (omitted when None) so phase plans round-trip through GeoJSON. - Documentation pass on the pp_interop README and module docstrings: - Add a "Two converters, two grid types" section explaining the balanced/asymmetric split and what it means for callers. - Fix factually-wrong README claims (3ph doesn't require phases to be populated; not all config knobs are exposed as kwargs). - Fix stale references in `phases/_common.py` module docstring (usage factor now in config, strategy signature now requires `usage_factor`, typo "fill that gap in for"). - Clarify `Pandapower3phGrid.source` doc, `_phase_split` `None` branch is now defensive only, `_round_robin` references `config.DEFAULT_USAGE_FACTOR` consistently with sibling. Co-Authored-By: Claude Opus 4.7 --- src/nopywer/models.py | 26 ++- src/nopywer/pp_interop/README.md | 131 +++++++++-- src/nopywer/pp_interop/__init__.py | 8 + src/nopywer/pp_interop/_conversion.py | 155 +++++++++---- src/nopywer/pp_interop/_powerflow_3ph.py | 207 +++++++++++++----- src/nopywer/pp_interop/phases/__init__.py | 8 + src/nopywer/pp_interop/phases/_common.py | 32 +-- src/nopywer/pp_interop/phases/_export.py | 85 +++++++ src/nopywer/pp_interop/phases/_round_robin.py | 5 +- tests/test_models.py | 25 +++ tests/test_pp_interop_phases.py | 134 ++++++++++++ tests/test_pp_interop_powerflow_3ph.py | 33 +-- 12 files changed, 695 insertions(+), 154 deletions(-) create mode 100644 src/nopywer/pp_interop/phases/_export.py 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 index 4324af1..2caa27d 100644 --- a/src/nopywer/pp_interop/README.md +++ b/src/nopywer/pp_interop/README.md @@ -15,8 +15,11 @@ Two solver paths live here: no balanced tool and no nopywer tree walk can produce. A separate `phases/` subpackage synthesises per-load phase -assignments for fixtures that arrive unphased. The asymmetric solver -needs that to be populated; the balanced solver ignores it. +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. @@ -66,11 +69,11 @@ theory and findings behind these choices. ▼ ▼ ┌────────────────────────┐ ┌──────────────────────────────┐ │ to_pandapower(grid) │ │ to_pandapower_3ph(grid) │ - │ (balanced) │ │ (asymmetric — augments │ - │ │ │ the balanced net with │ - │ │ │ zero-seq params + swaps │ - │ │ │ phased loads for │ - │ │ │ asymmetric_loads) │ + │ → PandapowerGrid │ │ → Pandapower3phGrid │ + │ (balanced loads in │ │ (independent net; loads │ + │ net.load) │ │ split across net.load and │ + │ │ │ net.asymmetric_load by │ + │ │ │ node.phase) │ └──────────┬─────────────┘ └────────────────┬─────────────┘ │ │ ▼ ▼ @@ -97,7 +100,7 @@ theory and findings behind these choices. | **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. 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; override via kwargs to `to_pandapower(...)`. | +| **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 @@ -125,6 +128,57 @@ 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 with phase planning** — the full doc 12 pipeline: @@ -153,13 +207,64 @@ 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 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. + ## Things to remember -- `to_pandapower_3ph` **augments and mutates** the balanced net - produced by `to_pandapower` (pops phased loads out of `net.load`, - adds them to `net.asymmetric_load`). Don't reuse the same - `PandapowerGrid` for both balanced and asymmetric solves — convert - fresh each time. - `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. diff --git a/src/nopywer/pp_interop/__init__.py b/src/nopywer/pp_interop/__init__.py index 27a74c7..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`. """ @@ -20,6 +26,7 @@ compute_power_flow, ) from ._powerflow_3ph import ( + Pandapower3phGrid, PowerFlow3phResults, compute_power_flow_3ph, to_pandapower_3ph, @@ -27,6 +34,7 @@ from ._shortcircuit import compute_short_circuit __all__ = [ + "Pandapower3phGrid", "PandapowerGrid", "PowerFlow3phResults", "PowerFlowResults", 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 index 47002bc..39ab981 100644 --- a/src/nopywer/pp_interop/_powerflow_3ph.py +++ b/src/nopywer/pp_interop/_powerflow_3ph.py @@ -27,10 +27,18 @@ (`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 on the balanced `to_pandapower` and -augments it rather than duplicating the conversion: it bolts the -zero-sequence params onto the lines and ext_grid, and swaps each -explicitly-phased load's balanced `pp.load` for a `pp.asymmetric_load`. +`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 @@ -46,11 +54,61 @@ """ import math -from dataclasses import dataclass +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 PandapowerGrid, _import_pandapower, to_pandapower +from ._conversion import _build_net_skeleton, _import_pandapower + +if TYPE_CHECKING: + from pandapower.auxiliary import pandapowerNet +else: + pandapowerNet = Any + + +@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]: @@ -64,9 +122,15 @@ def _phase_split(phase: object, power_watts: float) -> tuple[float, float, float - `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 (`None`, the `"U"` / `"Y"` sub-grid markers, an - empty or malformed list) — balanced: an even third on each - leg. + - 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. @@ -88,83 +152,107 @@ def _phase_split(phase: object, power_watts: float) -> tuple[float, float, float def to_pandapower_3ph( - grid: PandapowerGrid | object, *, load_pf: float = PF, **to_pp_kwargs: object -) -> PandapowerGrid: - """Convert a `PowerGrid` for asymmetric flow. + grid: PowerGrid, + *, + load_pf: float = PF, + **skeleton_kwargs: object, +) -> Pandapower3phGrid: + """Convert a `PowerGrid` for asymmetric flow — fresh net, no mutation. - Starts from the balanced `to_pandapower` net and augments it for - `runpp_3ph`: + 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. **Asymmetric loads** — every load with an explicit `phase` - (single int or list) has its balanced `pp.load` dropped and - replaced with a `pp.asymmetric_load` carrying the per-leg - split from `_phase_split`. Truly balanced loads (`phase` - `None`) keep their `pp.load` — `runpp_3ph` already treats - that as an even three-phase draw. - - The returned `PandapowerGrid.load_idx` is pruned to only the - loads that remain balanced `pp.load`s; the asymmetric ones live - in `net.asymmetric_load` and are keyed by name there. + 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 per-leg reactive - power for the asymmetric loads. Defaults to the global - `PF`. - **to_pp_kwargs: forwarded verbatim to `to_pandapower` - (generator ratings, `x_ohm_per_km`, etc.). + 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 `PandapowerGrid` whose `net` is ready for + A `Pandapower3phGrid` whose `net` is ready for `compute_power_flow_3ph`. Raises: - ValueError: propagated from `to_pandapower` if the grid has - no cables or a cable references an unknown node. + 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() - pp_grid = to_pandapower(grid, load_pf=load_pf, **to_pp_kwargs) - net = pp_grid.net + net, bus_idx = _build_net_skeleton(grid, **skeleton_kwargs) # type: ignore[arg-type] - # 1. zero-sequence line parameters + # 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 - # 2. source vector group / earthing + # source vector group / earthing net.ext_grid["r0x0_max"] = config.SOURCE_R0X0_MAX net.ext_grid["x0x_max"] = config.SOURCE_X0X_MAX - # 3. swap explicitly-phased loads for asymmetric loads + # 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 - p_l1, p_l2, p_l3 = _phase_split(node.phase, node.power_watts) - if p_l1 == p_l2 == p_l3: - continue # balanced — to_pandapower's pp.load is already correct - - net.load.drop(index=pp_grid.load_idx.pop(name), inplace=True) - pp.create_asymmetric_load( - net, - bus=pp_grid.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 pp_grid + if node.phase is None: + 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 @@ -236,16 +324,17 @@ def worst_neutral_current(self) -> tuple[str, float] | None: return cid, current -def compute_power_flow_3ph(pp_grid: PandapowerGrid) -> PowerFlow3phResults: +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`. - The net must have been built by `to_pandapower_3ph` — a plain - `to_pandapower` net lacks the zero-sequence parameters and - `runpp_3ph` will not solve it. + 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`. diff --git a/src/nopywer/pp_interop/phases/__init__.py b/src/nopywer/pp_interop/phases/__init__.py index 5f2558f..66dc202 100644 --- a/src/nopywer/pp_interop/phases/__init__.py +++ b/src/nopywer/pp_interop/phases/__init__.py @@ -15,6 +15,12 @@ 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 @@ -24,6 +30,7 @@ apply_assignment, single_phase_candidates, ) +from ._export import phase_geojson from ._greedy import assign_greedy from ._round_robin import assign_round_robin @@ -34,5 +41,6 @@ "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 index 9621391..7078249 100644 --- a/src/nopywer/pp_interop/phases/_common.py +++ b/src/nopywer/pp_interop/phases/_common.py @@ -1,22 +1,28 @@ """Shared scaffolding for per-load phase distribution strategies. A festival grid that will be solved with pandapower's asymmetric -`runpp_3ph` needs every single-phase load to declare which leg -(L1 / L2 / L3) it sits on. The strategies in this package fill that -gap in for fixtures that ship without phase data. +`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: - - the **usage factor** — festival loads rarely draw nameplate - power simultaneously, so balancing decisions use an assumed - fraction of nameplate (default 0.5x). - - **candidate selection** — 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** used to compare strategies. - -A strategy is just a function `PowerGrid -> PhaseAssignment`. It does -not mutate the grid; call `apply_assignment` to write the result back. + - **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 == diff --git a/src/nopywer/pp_interop/phases/_export.py b/src/nopywer/pp_interop/phases/_export.py new file mode 100644 index 0000000..f766fec --- /dev/null +++ b/src/nopywer/pp_interop/phases/_export.py @@ -0,0 +1,85 @@ +"""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}; " + f"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/_round_robin.py b/src/nopywer/pp_interop/phases/_round_robin.py index 750c5a4..28ab015 100644 --- a/src/nopywer/pp_interop/phases/_round_robin.py +++ b/src/nopywer/pp_interop/phases/_round_robin.py @@ -53,8 +53,9 @@ def assign_round_robin(grid: PowerGrid, *, usage_factor: float) -> PhaseAssignme result to `apply_assignment` to write it back. usage_factor: **required**, keyword-only. Assumed fraction of nameplate power loads draw together. No default — - see `DEFAULT_USAGE_FACTOR` (0.5) for the project-wide - reference figure. Does **not** change *which* leg a + 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 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_phases.py b/tests/test_pp_interop_phases.py index 224ef7f..356f54f 100644 --- a/tests/test_pp_interop_phases.py +++ b/tests/test_pp_interop_phases.py @@ -31,6 +31,7 @@ apply_assignment, assign_greedy, assign_round_robin, + phase_geojson, single_phase_candidates, ) from nopywer.pp_interop.phases._common import _fixed_leg_seed, build_assignment @@ -519,3 +520,136 @@ def test_strategies_run_on_field_export_and_greedy_balances_better(): 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. + """ + import json + + 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 index a7b7c8e..eb65362 100644 --- a/tests/test_pp_interop_powerflow_3ph.py +++ b/tests/test_pp_interop_powerflow_3ph.py @@ -84,29 +84,32 @@ def test_phase_split(phase, expected): # --- to_pandapower_3ph ------------------------------------------------------- -def test_to_3ph_swaps_phased_loads_for_asymmetric(): - """A phased load becomes an asymmetric_load; a balanced load stays a pp.load. +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 - `load_idx` is pruned down to just the load that stayed balanced. - - Why it matters: `to_pandapower_3ph` builds on the balanced - converter and *mutates* its output — dropping the balanced - `pp.load` rows for phased loads and re-adding them as asymmetric. - Get the swap wrong and a phased load is either counted twice (a - leftover `pp.load` plus the asymmetric one) or lost entirely. The - `load_idx` check guards the bookkeeping: it must end up describing - only the loads that remain plain `pp.load`s, or downstream - write-back keyed off it would touch the wrong rows. + 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 - # load_idx is pruned to the loads that stayed balanced - assert set(pp_grid.load_idx) == {"balanced"} + 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(): From 325edafaaa089b387c68d6823faad2f166e7762f Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 17 May 2026 21:37:24 +0200 Subject: [PATCH 12/19] feat(pp_interop, io): log silent coercions so they cannot regress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three silent coercions in the data path now emit log records so the operator has a paper trail when a fixture carries borderline data: - `nopywer.io.load_geojson`: - WARNING when a node's `phase` is a string (legacy "U"/"Y" sub-grid markers) — coerced to balanced as before. - DEBUG when a feature carries property keys not in the loader's known set — catches typos (`powr` for `power`) without being noisy on legitimate computed export fields. - `nopywer.pp_interop._powerflow_3ph.to_pandapower_3ph`: - WARNING when a string-marker phase appears; now also routes such loads to the balanced `pp.load` table (previously sent through `_phase_split` and stored as a balanced `pp.asymmetric_load`, which was semantically misleading). - `nopywer.pp_interop.phases._common._fixed_leg_seed`: - WARNING when a string-marker phase appears in the leg seed. Each warning is pinned by a focused test (`caplog`). Co-Authored-By: Claude Opus 4.7 --- src/nopywer/io.py | 57 +++++++++- src/nopywer/pp_interop/_powerflow_3ph.py | 17 ++- src/nopywer/pp_interop/phases/_common.py | 10 ++ tests/test_io_logging.py | 126 +++++++++++++++++++++++ tests/test_pp_interop_phases.py | 27 +++++ tests/test_pp_interop_powerflow_3ph.py | 34 ++++++ 6 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 tests/test_io_logging.py diff --git a/src/nopywer/io.py b/src/nopywer/io.py index e826c3a..f84ea28 100644 --- a/src/nopywer/io.py +++ b/src/nopywer/io.py @@ -19,6 +19,41 @@ "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", + "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.""" @@ -42,9 +77,20 @@ def load_geojson(source: str | Path | dict) -> tuple[dict[str, PowerNode], dict[ for feature in fc.get("features", []): geom = feature.get("geometry", {}) - props = _normalise_keys(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: @@ -70,6 +116,15 @@ def load_geojson(source: str | Path | dict) -> tuple[dict[str, PowerNode], dict[ 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/pp_interop/_powerflow_3ph.py b/src/nopywer/pp_interop/_powerflow_3ph.py index 39ab981..cfa8af9 100644 --- a/src/nopywer/pp_interop/_powerflow_3ph.py +++ b/src/nopywer/pp_interop/_powerflow_3ph.py @@ -53,6 +53,7 @@ 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 @@ -67,6 +68,8 @@ else: pandapowerNet = Any +logger = logging.getLogger(__name__) + @dataclass class Pandapower3phGrid: @@ -223,7 +226,19 @@ def to_pandapower_3ph( for name, node in grid.nodes.items(): if node.is_generator or node.power_watts <= 0: continue - if node.phase is None: + # 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, diff --git a/src/nopywer/pp_interop/phases/_common.py b/src/nopywer/pp_interop/phases/_common.py index 7078249..8db0ea6 100644 --- a/src/nopywer/pp_interop/phases/_common.py +++ b/src/nopywer/pp_interop/phases/_common.py @@ -44,11 +44,14 @@ 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 @@ -207,6 +210,13 @@ def _fixed_leg_seed(grid: PowerGrid, candidate_names: set[str], usage_factor: fl 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 diff --git a/tests/test_io_logging.py b/tests/test_io_logging.py new file mode 100644 index 0000000..450a111 --- /dev/null +++ b/tests/test_io_logging.py @@ -0,0 +1,126 @@ +"""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_pp_interop_phases.py b/tests/test_pp_interop_phases.py index 356f54f..7c79624 100644 --- a/tests/test_pp_interop_phases.py +++ b/tests/test_pp_interop_phases.py @@ -160,6 +160,33 @@ def test_fixed_leg_seed_spreads_balanced_and_lands_phased(): 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 -------------------------------------------------------- diff --git a/tests/test_pp_interop_powerflow_3ph.py b/tests/test_pp_interop_powerflow_3ph.py index eb65362..223f76a 100644 --- a/tests/test_pp_interop_powerflow_3ph.py +++ b/tests/test_pp_interop_powerflow_3ph.py @@ -174,6 +174,40 @@ def test_to_3ph_all_balanced_grid_has_no_asymmetric_loads(): 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 -------------------------------------------------- From 66c24cfaa540424510d176b3687f23f496ba575e Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 17 May 2026 21:43:40 +0200 Subject: [PATCH 13/19] fix(io): recognise legacy `cum_power` export key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Older nopywer exports (pre-2026) write `cum_power` instead of the current `cum_power_watts`. The 2026-05-14 martin fixture is in this shape — reloading it triggered one DEBUG record per node ("unknown key cum_power") which buried the actually-useful diagnostics. Add `cum_power` to the known-feature-keys allowlist so a round-tripped legacy export reloads silently. Co-Authored-By: Claude Opus 4.7 --- src/nopywer/io.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/nopywer/io.py b/src/nopywer/io.py index f84ea28..dd39aba 100644 --- a/src/nopywer/io.py +++ b/src/nopywer/io.py @@ -43,6 +43,7 @@ # / `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", From ef68c3215185f2798c7be1f03835fcf61aa0f35d Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 17 May 2026 21:47:10 +0200 Subject: [PATCH 14/19] docs(pp_interop): document logging surface in README Add a Logging section listing the four log records the module emits (string-marker WARNINGS in io / 3ph converter / leg seed, unknown-key DEBUG in io) and the operator action for each. Cross-reference from the Per-load phase row in the Inputs table. Co-Authored-By: Claude Opus 4.7 --- src/nopywer/pp_interop/README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/nopywer/pp_interop/README.md b/src/nopywer/pp_interop/README.md index 2caa27d..d86ac4e 100644 --- a/src/nopywer/pp_interop/README.md +++ b/src/nopywer/pp_interop/README.md @@ -98,7 +98,7 @@ theory and findings behind these choices. | **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. Use the `phases` package to synthesise an assignment if your fixture arrives unphased. | +| **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`. | @@ -263,6 +263,25 @@ the `Pandapower3phGrid`. `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`. From fbf3e01ffe20d2864a8becaccb3bb96fc4c10773 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 17 May 2026 21:52:27 +0200 Subject: [PATCH 15/19] feat(scripts): add run_asymmetric.py for reviewer-friendly 3ph workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/run_asymmetric.py` wraps the load → snap → (optional plan) → convert → solve → report pipeline so a reviewer can exercise the asymmetric path on any fixture without writing Python. Runs the balanced solve side-by-side so the "balanced says X, asymmetric says Y" gap documented in doc 12 is visible. Also adds a "What `phase = None` means at each layer" section to the pp_interop README, walking through every layer's handling of unphased loads — explicitly framing `None` as the modelling choice "balanced three-phase draw", not "missing data". Mirrors the expanded help text on the script's `--plan` flag. PR_DESCRIPTION.md drafts the message for the upstream review: what landed, why, how to try it, the four modelling assumptions that need a second look, and the research docs that motivate them. Co-Authored-By: Claude Opus 4.7 --- PR_DESCRIPTION.md | 166 +++++++++++++++++++++++++++++++ scripts/run_asymmetric.py | 165 ++++++++++++++++++++++++++++++ src/nopywer/pp_interop/README.md | 20 ++++ 3 files changed, 351 insertions(+) create mode 100644 PR_DESCRIPTION.md create mode 100644 scripts/run_asymmetric.py diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000..6125ff0 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,166 @@ +# 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 0.5× usage), 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 +# unphased fixture — solves like the balanced one +uv run python scripts/run_asymmetric.py \ + tests/fixtures/2026-05-14_martin_modified.geojson + +# with phase planning — actually exercises runpp_3ph +uv run python scripts/run_asymmetric.py \ + tests/fixtures/2026-05-14_martin.geojson \ + --plan greedy --usage-factor 0.5 +``` + +Reads the fixture, snaps cables, optionally plans phases, runs both +the balanced and asymmetric solves side-by-side, and prints the top +worst-leg voltage drops and worst-neutral cables. + +## 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). +- **`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, 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/scripts/run_asymmetric.py b/scripts/run_asymmetric.py new file mode 100644 index 0000000..3009921 --- /dev/null +++ b/scripts/run_asymmetric.py @@ -0,0 +1,165 @@ +"""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. + +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**. If you pass `--plan greedy` (or + `round_robin`), unphased loads get a synthesised assignment first; + without it, unphased loads stay `phase=None`. An unphased load + becomes a balanced `pp.load` under `runpp_3ph` (three-phase even + draw, zero neutral current — `pp.load` *is* the balanced-draw + primitive), so the asymmetric solver behaves much like the + balanced one. Pass `--plan` to actually exercise the asymmetric + machinery. +""" + +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, +) + +_STRATEGIES = {"greedy": assign_greedy, "round_robin": assign_round_robin} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Asymmetric 3-phase analysis on a nopywer GeoJSON fixture.", + 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.", + ) + parser.add_argument( + "--usage-factor", + type=float, + default=config.DEFAULT_USAGE_FACTOR, + help="Festival diversity factor used by --plan.", + ) + 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) + print(f"loaded {len(grid.nodes)} nodes, {len(grid.cables)} cables " + f"from {args.fixture}") + + # --- optional phase planning ------------------------------------ + if args.plan != "none": + plan = _STRATEGIES[args.plan](grid, usage_factor=args.usage_factor) + apply_assignment(grid, plan) + print(f"\nphase plan ({args.plan}, usage_factor={args.usage_factor}):") + print(f" loads assigned: {len(plan.phases)}") + print(f" balance: {plan.balance_pct:.2f}%") + print(f" leg totals (W): " + f"L1={plan.leg_totals_w[0]:.0f}, " + f"L2={plan.leg_totals_w[1]:.0f}, " + f"L3={plan.leg_totals_w[2]:.0f}") + + # --- 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 is past voltage collapse — runpp_3ph will fail too)") + + # --- asymmetric runpp_3ph --------------------------------------- + print("\n=== runpp_3ph (asymmetric) ===") + pp_3ph = to_pandapower_3ph(grid) + print(f" loads (balanced): {len(pp_3ph.balanced_load_idx)}") + print(f" loads (asymmetric): {len(pp_3ph.asymmetric_load_idx)}") + + try: + res = compute_power_flow_3ph(pp_3ph) + except Exception as exc: + print(f" did NOT converge: {type(exc).__name__}: {exc}") + 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/pp_interop/README.md b/src/nopywer/pp_interop/README.md index d86ac4e..8a9984c 100644 --- a/src/nopywer/pp_interop/README.md +++ b/src/nopywer/pp_interop/README.md @@ -240,6 +240,26 @@ 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 From abd2c58abc3fdf4128201a7475376af785b83eee Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 17 May 2026 22:07:39 +0200 Subject: [PATCH 16/19] refactor(scripts): collapse usage-factor + load-scale into single --load-factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier interface had two knobs that were conceptually adjacent but did different things — `--usage-factor` affected only phase planning, never the solve. Reviewers reading the output had no way to know which numbers the solver actually saw. Confusing in practice and the cautious-engineering "plan for X, stress at Y" workflow that two knobs would enable is rare enough not to deserve CLI surface. The script now exposes **one** knob, `--load-factor`, applied to both phase planning AND the pre-solve scaling pass. Order of operations: load -> snap -> plan (uses unmodified nameplate × factor) -> apply -> scale (mutates power_watts *= factor) -> convert -> solve. A pre-flight summary block prints the nameplate total, the load factor, the effective total demand the solvers actually see, and the chosen plan strategy, so the reader knows exactly what is being modelled before any results scroll past. Failure-mode hints now say "try a lower --load-factor" so the relationship to convergence is explicit. The script's module docstring carries the full rationale (when to use 1.0 / 0.5 / 0.2, why one knob and not two). README and PR_DESCRIPTION reference the script and the one-knob design. Library-level `usage_factor` parameter is unchanged — it's still required at every `phases` call site. Only the CLI surface changes. Co-Authored-By: Claude Opus 4.7 --- PR_DESCRIPTION.md | 26 +++-- scripts/run_asymmetric.py | 157 +++++++++++++++++++++++++------ src/nopywer/pp_interop/README.md | 13 +++ 3 files changed, 160 insertions(+), 36 deletions(-) diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md index 6125ff0..8ec45a6 100644 --- a/PR_DESCRIPTION.md +++ b/PR_DESCRIPTION.md @@ -71,19 +71,31 @@ phase_geojson( **Run the asymmetric solve** with a printable report: ```bash -# unphased fixture — solves like the balanced one +# already-phased fixture at festival diversity (0.5x nameplate) uv run python scripts/run_asymmetric.py \ - tests/fixtures/2026-05-14_martin_modified.geojson + tests/fixtures/2026-05-14_martin_modified.geojson \ + --load-factor 0.5 -# with phase planning — actually exercises runpp_3ph +# raw fixture with greedy phase planning uv run python scripts/run_asymmetric.py \ tests/fixtures/2026-05-14_martin.geojson \ - --plan greedy --usage-factor 0.5 + --plan greedy --load-factor 0.5 ``` -Reads the fixture, snaps cables, optionally plans phases, runs both -the balanced and asymmetric solves side-by-side, and prints the top -worst-leg voltage drops and worst-neutral cables. +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 diff --git a/scripts/run_asymmetric.py b/scripts/run_asymmetric.py index 3009921..e72a217 100644 --- a/scripts/run_asymmetric.py +++ b/scripts/run_asymmetric.py @@ -14,18 +14,67 @@ The balanced `runpp` is also run for comparison so you can read the "balanced says X, asymmetric says Y" gap that doc 12 documents. -Notes: +============================================================ +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**. If you pass `--plan greedy` (or - `round_robin`), unphased loads get a synthesised assignment first; - without it, unphased loads stay `phase=None`. An unphased load - becomes a balanced `pp.load` under `runpp_3ph` (three-phase even - draw, zero neutral current — `pp.load` *is* the balanced-draw - primitive), so the asymmetric solver behaves much like the - balanced one. Pass `--plan` to actually exercise the asymmetric - machinery. + - 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 @@ -52,9 +101,31 @@ _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.", + 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.") @@ -62,13 +133,22 @@ def main(argv: list[str] | None = None) -> int: "--plan", choices=("none", "greedy", "round_robin"), default="none", - help="Synthesise phase assignments for unphased loads before solving.", + 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( - "--usage-factor", + "--load-factor", type=float, default=config.DEFAULT_USAGE_FACTOR, - help="Festival diversity factor used by --plan.", + 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", @@ -98,20 +178,36 @@ def main(argv: list[str] | None = None) -> int: nodes, cables = load_geojson(args.fixture) grid = PowerGrid(nodes=nodes, cables=cables) _snap_cables_to_nodes(grid) - print(f"loaded {len(grid.nodes)} nodes, {len(grid.cables)} cables " - f"from {args.fixture}") - # --- optional phase planning ------------------------------------ + 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.usage_factor) + plan = _STRATEGIES[args.plan](grid, usage_factor=args.load_factor) apply_assignment(grid, plan) - print(f"\nphase plan ({args.plan}, usage_factor={args.usage_factor}):") + print(f"\nphase plan ({args.plan}):") print(f" loads assigned: {len(plan.phases)}") - print(f" balance: {plan.balance_pct:.2f}%") - print(f" leg totals (W): " - f"L1={plan.leg_totals_w[0]:.0f}, " - f"L2={plan.leg_totals_w[1]:.0f}, " - f"L3={plan.leg_totals_w[2]:.0f}") + print(f" balance: {plan.balance_pct:.2f}% " + f"(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) ===") @@ -121,25 +217,28 @@ def main(argv: list[str] | None = None) -> int: 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}%") + 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 is past voltage collapse — runpp_3ph will fail too)") + print(" (grid past voltage collapse — try a lower --load-factor)") # --- asymmetric runpp_3ph --------------------------------------- print("\n=== runpp_3ph (asymmetric) ===") pp_3ph = to_pandapower_3ph(grid) - print(f" loads (balanced): {len(pp_3ph.balanced_load_idx)}") - print(f" loads (asymmetric): {len(pp_3ph.asymmetric_load_idx)}") + print(f" loads (balanced -> net.load): " + f"{len(pp_3ph.balanced_load_idx)}") + print(f" loads (asymmetric -> net.asymmetric_load):" + f" {len(pp_3ph.asymmetric_load_idx)}") 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}") + print(f" converged: {res.converged}") worst_vdrop = sorted( ((name, leg + 1, drop) diff --git a/src/nopywer/pp_interop/README.md b/src/nopywer/pp_interop/README.md index 8a9984c..f922fc5 100644 --- a/src/nopywer/pp_interop/README.md +++ b/src/nopywer/pp_interop/README.md @@ -179,6 +179,19 @@ 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: From 81f87d7a77751983f39736c23acc93599aa3a612 Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 17 May 2026 22:09:17 +0200 Subject: [PATCH 17/19] polish(scripts): align 3ph load counts; validate --load-factor Three small fixes from the final review pass: - 3ph load-count summary lines align numerically (right-justified 3-digit width). - `--load-factor` rejects <= 0 and > 5 with actionable error messages, catching operator typos before they reach the solver. - PR_DESCRIPTION uses --load-factor consistently and notes that the library DEFAULT_USAGE_FACTOR is the CLI default too. Co-Authored-By: Claude Opus 4.7 --- PR_DESCRIPTION.md | 10 ++++++---- scripts/run_asymmetric.py | 25 ++++++++++++++++++++----- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md index 8ec45a6..6b8e6dc 100644 --- a/PR_DESCRIPTION.md +++ b/PR_DESCRIPTION.md @@ -46,9 +46,9 @@ solve structurally cannot: 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 0.5× usage), 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. +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 @@ -116,7 +116,9 @@ worth flagging here: - **`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). + 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 — diff --git a/scripts/run_asymmetric.py b/scripts/run_asymmetric.py index e72a217..1499091 100644 --- a/scripts/run_asymmetric.py +++ b/scripts/run_asymmetric.py @@ -82,6 +82,21 @@ import sys from pathlib import Path + +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 + from nopywer.analyze import _snap_cables_to_nodes from nopywer.io import load_geojson from nopywer.models import PowerGrid @@ -141,7 +156,7 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument( "--load-factor", - type=float, + type=_positive_float, default=config.DEFAULT_USAGE_FACTOR, help=( "Single multiplier on nameplate applied to BOTH phase " @@ -226,10 +241,10 @@ def main(argv: list[str] | None = None) -> int: # --- asymmetric runpp_3ph --------------------------------------- print("\n=== runpp_3ph (asymmetric) ===") pp_3ph = to_pandapower_3ph(grid) - print(f" loads (balanced -> net.load): " - f"{len(pp_3ph.balanced_load_idx)}") - print(f" loads (asymmetric -> net.asymmetric_load):" - f" {len(pp_3ph.asymmetric_load_idx)}") + 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) From 2f36b4f9d105edf0c8d169507f2354af87919a9d Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 17 May 2026 22:12:09 +0200 Subject: [PATCH 18/19] docs: drop stale 'augments balanced net' descriptions of to_pandapower_3ph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `to_pandapower_3ph` was refactored earlier in the branch to build an independent net via the shared `_build_net_skeleton`, but two descriptive docstrings still described the old mutation-based shape: - research/pandapower/10's status-summary footer - tests/test_pp_interop_powerflow_3ph.py module docstring Updated both to match what the code now does. Also expanded PR description's reference to the README to call out the per-layer `phase = None` table explicitly — it's the clearest single piece of behavioural documentation a reviewer can read. Co-Authored-By: Claude Opus 4.7 --- PR_DESCRIPTION.md | 5 ++++- .../10_three_phase_asymmetric_modelling.md | 14 ++++++++++---- .../11_field_export_fixture_walkthrough.md | 2 +- research/pandapower/12_runpp_3ph_findings.md | 4 ++-- research/pandapower/README.md | 2 +- tests/test_pp_interop_powerflow_3ph.py | 7 ++++--- 6 files changed, 22 insertions(+), 12 deletions(-) diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md index 6b8e6dc..db78e4e 100644 --- a/PR_DESCRIPTION.md +++ b/PR_DESCRIPTION.md @@ -143,7 +143,10 @@ Also worth a second pair of eyes: ## Required reading for the technical reviewer - `src/nopywer/pp_interop/README.md` — API surface, runnable examples, - the balanced/asymmetric type distinction, logging surface. + 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 diff --git a/research/pandapower/10_three_phase_asymmetric_modelling.md b/research/pandapower/10_three_phase_asymmetric_modelling.md index d47e235..f12c3bd 100644 --- a/research/pandapower/10_three_phase_asymmetric_modelling.md +++ b/research/pandapower/10_three_phase_asymmetric_modelling.md @@ -399,10 +399,16 @@ that calls `pp.runpp_3ph` instead of `runpp`. | 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` — it augments the -balanced `to_pandapower` net (zero-sequence line/source params, -asymmetric loads swapped in for phased loads) rather than branching -inside `_conversion.py`, keeping the balanced path untouched. +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, diff --git a/research/pandapower/11_field_export_fixture_walkthrough.md b/research/pandapower/11_field_export_fixture_walkthrough.md index 36ee3e9..c7848c9 100644 --- a/research/pandapower/11_field_export_fixture_walkthrough.md +++ b/research/pandapower/11_field_export_fixture_walkthrough.md @@ -150,7 +150,7 @@ For Martin, concretely: 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` — Martin's note that the phase work went + 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 diff --git a/research/pandapower/12_runpp_3ph_findings.md b/research/pandapower/12_runpp_3ph_findings.md index b81f823..9180442 100644 --- a/research/pandapower/12_runpp_3ph_findings.md +++ b/research/pandapower/12_runpp_3ph_findings.md @@ -16,7 +16,7 @@ the numbers do *not* support. ## What was run - **Fixture**: `tests/fixtures/2026-05-14_martin_modified.geojson` — - Martin's 2026 east-grid export, with the four cable resizes from + 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 @@ -181,7 +181,7 @@ rather than per-leg drop. - **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 Martin's + 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 diff --git a/research/pandapower/README.md b/research/pandapower/README.md index 7f8bb87..95227df 100644 --- a/research/pandapower/README.md +++ b/research/pandapower/README.md @@ -60,7 +60,7 @@ Date of research: April 2026. Pandapower latest at time of writing: **3.4.0** 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 Martin's `2026-05-14_martin.geojson` field export into + 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 diff --git a/tests/test_pp_interop_powerflow_3ph.py b/tests/test_pp_interop_powerflow_3ph.py index 223f76a..f2a404b 100644 --- a/tests/test_pp_interop_powerflow_3ph.py +++ b/tests/test_pp_interop_powerflow_3ph.py @@ -6,9 +6,10 @@ - `_phase_split` turns a `PowerNode.phase` field into a per-leg power tuple the same way the rest of nopywer does; - - `to_pandapower_3ph` augments the balanced net correctly — - zero-sequence params on lines and ext_grid, asymmetric loads - swapped in for phased loads; + - `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 From 7446a16239d36603fd38d2b6ca8fae9899c35ade Mon Sep 17 00:00:00 2001 From: Harry Salmon Date: Sun, 17 May 2026 22:40:22 +0200 Subject: [PATCH 19/19] fix: satisfy ruff lint and format --- scripts/run_asymmetric.py | 64 ++++++++++++------------ src/nopywer/pp_interop/phases/_common.py | 4 +- src/nopywer/pp_interop/phases/_export.py | 5 +- tests/test_io_logging.py | 4 +- tests/test_pp_interop_phases.py | 2 - 5 files changed, 34 insertions(+), 45 deletions(-) diff --git a/scripts/run_asymmetric.py b/scripts/run_asymmetric.py index 1499091..602635e 100644 --- a/scripts/run_asymmetric.py +++ b/scripts/run_asymmetric.py @@ -82,21 +82,6 @@ import sys from pathlib import Path - -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 - from nopywer.analyze import _snap_cables_to_nodes from nopywer.io import load_geojson from nopywer.models import PowerGrid @@ -113,14 +98,26 @@ def _positive_float(raw: str) -> float: 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 - ) + 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: @@ -213,12 +210,15 @@ def main(argv: list[str] | None = None) -> int: apply_assignment(grid, plan) print(f"\nphase plan ({args.plan}):") print(f" loads assigned: {len(plan.phases)}") - print(f" balance: {plan.balance_pct:.2f}% " - f"(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") + 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: @@ -229,9 +229,7 @@ def main(argv: list[str] | None = None) -> int: 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] - ) + 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: @@ -256,18 +254,18 @@ def main(argv: list[str] | None = None) -> int: 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)), + ( + (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] + 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") diff --git a/src/nopywer/pp_interop/phases/_common.py b/src/nopywer/pp_interop/phases/_common.py index 8db0ea6..4aeadb4 100644 --- a/src/nopywer/pp_interop/phases/_common.py +++ b/src/nopywer/pp_interop/phases/_common.py @@ -106,9 +106,7 @@ class PhaseAssignment: balance_pct: float -def single_phase_candidates( - grid: PowerGrid, *, usage_factor: float -) -> list[tuple[str, 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: diff --git a/src/nopywer/pp_interop/phases/_export.py b/src/nopywer/pp_interop/phases/_export.py index f766fec..5b3b097 100644 --- a/src/nopywer/pp_interop/phases/_export.py +++ b/src/nopywer/pp_interop/phases/_export.py @@ -68,10 +68,7 @@ def phase_geojson( FileNotFoundError: if `input_path` does not exist. """ if strategy not in _STRATEGIES: - raise ValueError( - f"unknown strategy {strategy!r}; " - f"expected one of {sorted(_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) diff --git a/tests/test_io_logging.py b/tests/test_io_logging.py index 450a111..c16abd6 100644 --- a/tests/test_io_logging.py +++ b/tests/test_io_logging.py @@ -83,9 +83,7 @@ def test_unknown_property_key_logs_a_debug_diagnostic(caplog): 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() + 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() diff --git a/tests/test_pp_interop_phases.py b/tests/test_pp_interop_phases.py index 7c79624..cf27900 100644 --- a/tests/test_pp_interop_phases.py +++ b/tests/test_pp_interop_phases.py @@ -607,8 +607,6 @@ def test_phase_geojson_matches_step_by_step_pipeline(tmp_path): relying on `phase_geojson` would silently get a different plan from a user reading the README's step-by-step snippet. """ - import json - from nopywer.io import load_geojson src = FIXTURES / "2026-05-14_martin.geojson"