Skip to content

feat: pandapower comparison tests + sensitivity analysis (stacked on PR #13) - #1

Closed
harrysalmon wants to merge 16 commits into
feat/pandapower-interopfrom
feat/pandapower-comparison-tests
Closed

feat: pandapower comparison tests + sensitivity analysis (stacked on PR #13)#1
harrysalmon wants to merge 16 commits into
feat/pandapower-interopfrom
feat/pandapower-comparison-tests

Conversation

@harrysalmon

@harrysalmon harrysalmon commented May 10, 2026

Copy link
Copy Markdown
Owner

Stacked on top of PR #13

This PR sits on top of `feat/pandapower-interop` (the base PR going into vfinel:develop). Review vfinel#13 first — its conversion layer + AC validator are what these tests / docs build on. Once vfinel#13 lands, the base of this PR can flip to `develop`.

Summary

Eight commits adding comparison tests, topology coverage, sensitivity analysis, and a refactor of `RHO_COPPER` into traceable components.

```
4ea2057 refactor(constants): decompose RHO_COPPER into traceable components
5bc2729 docs(research): sensitivity to pp_interop config assumptions
b6b9d39 chore(scripts): sensitivity sweep over high-impact pp_interop config
347172d test(pp_interop): topology comparison tests for three festival shapes
d6cd989 docs(test): explain what each pp_interop test is comparing and why
beb3393 docs(research): why tree walk and pandapower AC flow disagree
488f99b test(pp_interop): side-by-side comparison vs analyze on each fixture
f160e1f test(pp_interop): parity with analyze on the canonical voltage-drop fixture
```

What's in here

Tests (7 new tests, 0 modified, 51/51 pass total)

  • `test_pp_interop_parity.py` — mirror Vincent's canonical `test_compute_voltage_drop_uses_phase_voltage_reference` through pandapower with matched assumptions (constant-current load). Proves the conversion is faithful.
  • `test_pp_interop_comparison.py` — three side-by-side comparisons on the existing analyze fixtures. Captures the three shapes of disagreement: low-drop balanced (parity), unbalanced single-phase (nopywer over-states), high-drop balanced (nopywer under-states).
  • `test_pp_interop_topology.py` — three synthetic festival layouts (deep chain, wide star, bottleneck) isolating the effect of grid shape on the gap.

Scripts

  • `scripts/sensitivity_sweep.py` — tight sweeps around the high-impact `pp_interop/config.py` defaults (`X_OHM_PER_KM`, `PF`, `GEN_XDSS_PU`). Prints tables. Findings:
    • X is essentially negligible at festival scales (0.06 pp range)
    • PF is a modest ±0.2 pp lever
    • GEN_XDSS_PU moves Isc by 2.2× across realistic generators — biggest lever, worth overriding per-event

Docs (research/pandapower/)

  • `08_why_tree_walk_and_ac_disagree.md` — power-systems theory walkthrough (V_PN vs V_LL conventions, ZIP load model, why the gap runs in opposite directions on different fixtures).
  • `09_sensitivity_to_config_assumptions.md` — sweep findings with per-knob takeaways and verdicts.

Refactor

`src/nopywer/constants.py` — `RHO_COPPER` now decomposed into:

RHO_COPPER  =  RHO_COPPER_20C  ×  k_temp(COPPER_TEMP_C)  ×  FIELD_MARGIN
            ≈      (1/58)      ×        1.197             ×    1.864
            ≈   1/26    (preserved exactly to within 0.02 %)

Each component independently overridable. The "Rich's notes" provenance is preserved in the comment as the historical origin.

Test plan

Why draft

Sitting in draft until PR #13 is reviewed. Once vfinel#13 lands, this PR's base will flip to develop and it can come out of draft.

🤖 Generated with Claude Code

…ixture

Mirrors test_compute_voltage_drop_uses_phase_voltage_reference
(test_analyze.py) but routes the same hand-crafted fixture through
pandapower's runpp and asserts both produce 220 V / 4.35 %.

Same fixture: 26 m of 1 mm² cable, single-phase 10 A. nopywer's
RHO_COPPER = 1/26 makes R = 1.0 Ω exactly, so ΔV = R · I = 10 V.

Key insight: nopywer's tree walk computes I = P / V0 / PF once at
nominal voltage and uses that current everywhere — that's exactly
pandapower's `const_i_p_percent=100` constant-current load model.
Under matched load assumptions (and PF=1, no cable X) the two tools
land on the same answer to within 0.05 V / 0.02 % vdrop.

This is the conversion-correctness test. Any future divergence on
real fixtures (test_pp_interop_comparison.py) is then known to come
from physics nopywer simplifies away — load feedback, reactive
power, line reactance — not from a translation bug.
Pin the tree-walk-vs-AC divergence on every existing analyze fixture so
future changes to either side are visible. Three fixtures cover the
three "shapes" of disagreement:

1. analyze_named_generator (small balanced load) — both tools agree to
   within rounding (<0.1 V / 0.05 %). The conversion is faithful in
   the regime where physics simplifications don't bite.

2. analyze_input (unbalanced single-phase loads) — nopywer's
   `max(current_per_phase)` rule pushes the tree walk's reported drop
   ABOVE pandapower's balanced runpp. At load_b: tree walk says 8.13 %,
   AC says 3.12 % — tree walk overstates by ~5 pp because it's
   conservative-by-design, while our converter currently collapses
   single-phase loads to balanced (runpp_3ph is opportunity §3 in
   06_extended_opportunities.md).

3. input_nodes (the 2025 event, optimised) — constant-power feedback
   at low local voltage means more current, more drop. Tree walk
   computes I at nominal V0 once and never iterates, so it
   UNDERSTATES drop by ~12 pp at the worst node. This is the
   headline finding from 07_optimiser_validation_findings.md,
   captured here as a regression check.

The two effects are physically real and pull in opposite directions.
Tests assert direction-of-disagreement plus pinned numbers to ±2 pp,
so any improvement to either tool's modelling will require a conscious
update to these expectations.
Power-systems theory walkthrough of the divergence captured by
tests/test_pp_interop_comparison.py. Covers:

- The 230 V P-N vs 400 V L-L convention (and where it bites in interop)
- The ZIP load model (constant-Z, constant-I, constant-P)
- Why nopywer's tree walk is implicitly a constant-current solver
- Why pandapower's runpp is constant-power and iterates to equilibrium

Then the three mechanisms that make the two tools disagree, in detail:

1. Small balanced load → both shortcuts inactive → tools agree to
   rounding (the conversion-correctness check).
2. Unbalanced single-phase → nopywer's max(I_per_phase) rule
   over-states drop while pandapower's balanced collapse
   under-states it. Neither is right; truth needs runpp_3ph.
3. High-stress balanced → nopywer's constant-I shortcut means it
   misses the V-drops-→-more-current-→-more-drops feedback that
   AC iterates to equilibrium. Tree walk under-states by ~12 pp.

Closes the loop on the comparison tests by explaining WHY each
direction shows up.
Adds module-level docs and per-test docstrings across the three
pp_interop test files. Each test now spells out:

- "What this is comparing" — the two sides being lined up
- "What this is testing" — which property is being asserted
- "Real-world scenario" — the festival situation the test models

The three files now read as a deliberate three-layer story:

  test_pp_interop.py
      Smoke / conversion / API tests on a trivial 1-cable grid.
      Festival mapping: any layout would touch this code, so any
      silent unit/convention bug would show here first.

  test_pp_interop_parity.py
      Mirrors the canonical analyze unit test through pandapower
      with matched assumptions (constant-current load, no X). Proves
      the conversion is faithful — any divergence elsewhere is
      physics, not a bug.

  test_pp_interop_comparison.py
      Three tests, three festival scenarios, three "shapes" of
      tree-walk-vs-AC disagreement:
        1. small distro near the generator → both tools agree
        2. unbalanced single-phase distro → nopywer over-states
        3. long radial trunk to heavy load → nopywer under-states

Tests unchanged; assertions identical. Pure documentation.
Adds Group A from the coverage gap analysis: synthetic fixtures that
isolate the effect of grid SHAPE on the tree-walk-vs-AC gap, with
load type and config defaults held constant.

Three tests, three layouts a planner regularly sketches:

- Deep linear chain (gen→A→B→C→D, each tapping 5 kW through a 32 A
  cable). Tests constant-power feedback compounding with depth: the
  gap grows monotonically (A=+0.02, B=+0.07, C=+0.10, D=+0.12 pp).
  Models a back-of-house corridor: kitchen → dressing → green room
  → workshop, each distro tapping its load.

- Wide star (gen → 5 parallel branches × 5 kW each). Tests branch
  independence: with no chaining there's no feedback compounding.
  All branches identical (tw 1.78 % / ac 1.65 % / gap -0.13 pp at
  every leaf). Models a central distro hub feeding stages
  radially — the case where the planner can trust the tree walk.

- Bottleneck (thin 16 A trunk → distro → three 32 A spurs).
  Tests drop attribution: even when magnitudes diverge, both
  tools should agree the trunk dominates. They do — trunk carries
  96 % of the total drop to any leaf, in both views. Models the
  case where a planner reuses an existing thin cable run and
  fans out from there.

Each test pins direction-of-disagreement plus magnitude bounds
(±0.5 pp on the gap, >75 % attribution share). Synthetic fixtures
are built in code via two small helpers (_node, _cable) that
populate `power_per_phase` the way io.load_geojson would.

Module docstring frames all three as "sensible planner shapes" and
links to research/pandapower/08 for the underlying physics.
Adds scripts/sensitivity_sweep.py — three tight sweeps around the
defaults in pp_interop/config.py, each printing a table showing how
the AC vs tree-walk gap responds.

Findings on the deep-chain fixture (4 cables × 50 m, 5 kW × 4 loads):

  X_OHM_PER_KM     0.05 → 0.12 (default 0.08)
       AC drop at d:  11.312 % → 11.373 %      (span 0.06 pp)
       essentially negligible at festival scales — the 0.08
       default is fine even if individual cables differ ±50 %.

  PF               0.80 → 1.00 (default 0.9)
       AC drop at d:  11.543 % → 11.151 %      (span 0.39 pp)
       gap (AC − tree): +0.323 → −0.069 pp
       crosses zero around PF ≈ 0.97. Real festival kitchens at
       PF ≈ 0.7 would push the gap further open; this is the
       knob worth varying per-load if/when load_type lands.

  GEN_XDSS_PU      0.08 → 0.18 (default 0.12)
       Isc at gen:    1.811 kA → 0.805 kA      (2.2× range)
       biggest lever overall. Choice of generator impedance
       materially affects breaker breaking-capacity sizing.

Run with:
    uv run python scripts/sensitivity_sweep.py

Output is reproducible (no randomness). Not a test because the
useful artifact is the printed table, not a pass/fail; pinning
specific numbers as test assertions would obscure the gradient
we actually care about.
Captures the findings from running scripts/sensitivity_sweep.py:

  X_OHM_PER_KM    0.05 → 0.12  →  AC drop moves 0.06 pp  (negligible)
  PF              0.80 → 1.00  →  AC drop moves 0.39 pp  (gap flips at PF≈0.97)
  GEN_XDSS_PU     0.08 → 0.18  →  Isc moves 2.2×         (1.81 → 0.81 kA)

Verdict per knob: X is fine to leave at 0.08 (festival cables are
short and resistive enough that the X·sin φ term barely contributes).
PF is a modest lever and the most useful per-load knob if/when
load_type lands. GEN_XDSS_PU is the one to override per-event —
ignoring the actual hired generator's X''d can mis-size breakers
by a factor of 2.

Cross-cutting: at festival scales the AC vs tree-walk gap is
dominated by cable R and topology, not by these per-cable / per-load
constants. RHO_COPPER deliberately not swept because both tools
share it (gap invariant; absolute numbers would scale).

Reproducible: re-run scripts/sensitivity_sweep.py after any change
to pp_interop/config.py defaults to refresh the numbers in this doc.
The historical RHO_COPPER = 1/26 ("Rich's notes") bundled together
three distinct effects that were previously opaque. Split them out
without changing the numerical default:

    RHO_COPPER  =  RHO_COPPER_20C  ×  k_temp(COPPER_TEMP_C)  ×  FIELD_MARGIN
                ≈      (1/58)      ×        1.197             ×    1.864
                ≈   0.0385  ≈  1/26

Each component is now independently overridable:

  - RHO_COPPER_20C = 1/58 — annealed copper at 20 °C (IACS standard).
    Physics; not really tunable.
  - ALPHA_COPPER = 0.00393 — temperature coefficient at 20 °C.
  - COPPER_TEMP_C = 70 — assumed steady-state conductor T under
    festival load. Lower for cool events; raise for coiled-drum-
    in-the-sun scenarios.
  - FIELD_MARGIN = 1.864 — empirical catch-all for connector contact
    resistance, bundling, ageing, etc. Calibrated to reproduce the
    legacy 1/26 figure at the default temperature.

The product at the defaults reproduces 1/26 to within 0.02 %, well
below modelling noise. All 51 tests pass.

One test (`test_to_pandapower_topology`) was hardcoding `1000/26` for
the expected r_ohm_per_km; updated to use `RHO_COPPER` directly so
it tracks the actual constant rather than the legacy literal.

The "Rich's notes" provenance is preserved in the comment, framed
as the historical origin of the figure rather than its current
definition.
Documents what would be needed to move from balanced runpp to
asymmetric runpp_3ph — the right fix for mechanism 2 (the unbalanced
single-phase divergence captured in 08).

Three categories of input pandapower needs:

  1. Per-load phase assignment (already in PowerNode.phase, just
     not always populated in fixtures)
  2. Per-cable zero-sequence params (r0, x0, c0 — engineering
     defaults of r0/r1 ≈ x0/x1 ≈ 4 for 4-core LV flex)
  3. Source vector group + zero-sequence source impedance
     (most festival diesel gen-sets are TN-S star-grounded, Yn)

Probed the existing fixtures for what's already there:

  - input_nodes.geojson (2025 production fixture, 51 nodes):
    every load has phase=None. Modelled balanced not because the
    festival was balanced but because the design doc was created
    before phase assignment was decided.
  - analyze_input.geojson (small synthetic): has phase=1, phase=2
    on its two loads. Could be modelled three-phase today.

Three strategies for filling in the missing 2025-fixture phase
data: historical reconstruction (most realistic, requires field
records), round-robin synthesis (cheap and reproducible — the
recommended default), or all-on-L1 worst-case stress.

Implementation cost estimate: ~5 hours total across data,
config, code, tests, and findings doc. Would land as
feat/pandapower-runpp-3ph stacked on this PR.

Updates README index to include doc 10.
Removes both kinds of time guesses across the research docs:

- Implementation-effort estimates (hours/minutes per layer in
  doc 10's "Estimated cost" table, plus the "~5 hours total"
  summary). The per-layer LINE counts are kept since those are
  scope indicators, not effort guesses. Table renamed
  "Scope summary".

- Reading-time estimates (per-doc and per-file minutes in
  REVIEWING.md, plus the "~25 minutes / ~15 more" preamble).

The README's reference to doc 10's "5-hour cost estimate" is
updated to "per-layer scope breakdown" to match.

Effort guesses are usually wrong and tend to be quoted as if
they were measurements; dropping them avoids anchoring future
implementers to a number that has no basis.
@harrysalmon
harrysalmon requested review from martianbug and vfinel May 11, 2026 19:36
feat: pandapower interop — IEC 60909 short-circuit + AC validation of the optimiser
@vfinel

vfinel commented May 15, 2026

Copy link
Copy Markdown
Collaborator

the vfinel#13 has been merged, as suggested, the base of this PR can flip to develop ! Thanks

@vfinel

vfinel commented May 15, 2026

Copy link
Copy Markdown
Collaborator

I reviewed the change, it is all good to me. Can't wait to see whatever comes next, the research documentation is teasing ! The merge can be done when you're ready. Thanks for your work !

@vfinel

vfinel commented May 15, 2026

Copy link
Copy Markdown
Collaborator

Also, be aware that the concept of "Y" and "R" phases, that was used to distinguish the "yellow" and "red" sub-grids, is going to be removed as it is not relevant anymore. I did the deletion in the qgis_plugin branch, but that wasn't merged yet. So you could already remove the mention of those concepts in the documentation if you feel like it.

@vfinel vfinel left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the change, it is all good to me. Can't wait to see whatever comes next, the research documentation is teasing ! The merge can be done when you're ready. Thanks for your work !

NB: note that the concept of "Y" and "R" phases, that was used to distinguish the "yellow" and "red" sub-grids, is going to be removed as it is not relevant anymore. I did the deletion in the qgis_plugin branch, but that wasn't merged yet. So you could already remove the mention of those concepts in the documentation if you feel like it.

@harrysalmon

Copy link
Copy Markdown
Owner Author

Superseded by the parent-repo PR against develop: vfinel#14

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants