Skip to content

Translate the new-format network tables to buses and links#126

Open
nick-gorman wants to merge 6 commits into
rename-transmission-default-limitfrom
translate-network-to-buses-links
Open

Translate the new-format network tables to buses and links#126
nick-gorman wants to merge 6 commits into
rename-transmission-default-limitfrom
translate-network-to-buses-links

Conversation

@nick-gorman

@nick-gorman nick-gorman commented Jun 24, 2026

Copy link
Copy Markdown
Member

This PR does two things: it adds the translator that turns the new-format network tables into PyPSA buses and links, and it uses those tables to pilot a "wildcard" convention for sparse inputs — implemented end-to-end so there is a concrete example to react to before deciding whether to adopt it more broadly.

src/ispypsa/
├── translator/
│   ├── network.py            → network tables → buses + links (+ link_timeslice_limits)
│   └── helpers.py            → _resolve_wildcards, the generic wildcard resolver
└── validation/schemas/
    ├── network_transmission_path_limits.yaml           → wildcard keys, nan_fill, coverage rules
    ├── network_expansion_options.yaml                  → wildcard keys, pairing rule
    └── network_transmission_path_expansion_costs.yaml  → wildcard keys, nan_fill, coverage rules
tests/test_translator/
├── test_network.py            → buses/links coverage incl. wildcard and guard cases
└── test_translator_helpers.py → _resolve_wildcards unit coverage

The translation

network.py consumes network_geography, network_transmission_paths, network_transmission_path_limits, network_expansion_options and network_transmission_path_expansion_costs, and produces:

  • one bus per modelled geography (REZ buses only when REZs are modelled as discrete nodes),
  • one existing link per transmission path,
  • expansion links per investment period, from the unified options/costs tables.

Flow paths and REZ connections run through one pipeline — both are just paths.

A link's p_nom is the larger of its forward and reverse capacities; every directional, per-demand-condition limit is expressed per unit of that in a link_timeslice_limits table (forward limits as p_max_pu, reverse as p_min_pu), which pypsa_build later expands into per-snapshot series:

network_transmission_path_limits:
    path_id  direction  timeslice             capacity
    CQ-NQ    forward    qld_peak_demand       1200
    CQ-NQ    forward    qld_winter_reference  1400
    CQ-NQ    reverse    qld_peak_demand       1440
    CQ-NQ    reverse    qld_winter_reference  1910

links (abridged):
    name            p_nom  p_min_pu  p_max_pu
    CQ-NQ_existing  1910   0.0       1.0       # p_nom = max over both directions

link_timeslice_limits:
    name            attribute  timeslice             value
    CQ-NQ_existing  p_max_pu   qld_peak_demand        0.628   # 1200/1910
    CQ-NQ_existing  p_max_pu   qld_winter_reference   0.733
    CQ-NQ_existing  p_min_pu   qld_peak_demand       -0.754   # -1440/1910
    CQ-NQ_existing  p_min_pu   qld_winter_reference  -1.0

Design choices worth knowing before reading the diff:

  • p_nom as the max of both directions keeps every per-unit limit in [-1, 1], so no timeslice can credit a link above its physical rating.
  • The static p_min_pu/p_max_pu on each link are inert defaults (0.0/1.0); the real limits live entirely in link_timeslice_limits under a coverage contract (Translator timeslice implementation notes (decisions from staging PR #121) #123) — every snapshot is covered by a named-timeslice row or a blank-timeslice fallback.
  • Expansion links start at p_nom 0 and are individually unbounded; the expansion-limit custom constraints (ispypsa.translator.constraints) cap the total built across a path's links. Asymmetric options set p_max_pu/p_min_pu in the option's forward/reverse proportion.

The wildcard proposal (feedback wanted)

The three sparse tables (limits, expansion options, expansion costs) accept blank key cells as wildcards, resolved most-specific-wins, with blank value cells taking a schema-declared nan_fill:

network_transmission_path_limits (blank cells are wildcards):
    path_id  direction  timeslice  capacity
    CQ-NQ    forward               1500
    CQ-NQ    reverse               1000
                                   800       # table-wide default

resolved against modelled paths {CQ-NQ, Q1-NQ}:
    path_id  direction  timeslice  capacity
    CQ-NQ    forward               1500      # its own rows win (fewest wildcards)
    CQ-NQ    reverse               1000
    Q1-NQ    forward               800       # inherits the table-wide default
    Q1-NQ    reverse               800

_resolve_wildcards in translator/helpers.py is the generic resolver; the three schema files document the convention (wildcard keys, *_resolve_unambiguously and coverage rules, nan_fill). The validation rules are declarative-only for now — two runtime guards in network.py stand in for the option-pairing rule until schema validation lands.

This is deliberately a reference implementation. The same convention could apply to most sparse ISPyPSA tables (build costs, fuel prices, generator properties); the question for review is whether it earns its keep as a uniform way to express defaults and sparse overrides, or whether it's over-engineering that will be hard to apply consistently. Either way, concrete code seemed a better basis for that call than an abstract proposal.

Scope note

The translation code is dark — nothing calls it until the orchestrator PR, so there's no change to production behaviour. This PR is stacked on #129 (the transmission_default_limit rename) and targets that branch as its base; once #129 merges it retargets to main.

🤖 Generated with Claude Code

network_geography becomes buses (REZ buses dropped when rezs is
attached_to_parent_node), and the path/limit/expansion tables become
links. The winter_reference limit is the link's static p_nom — winter is
the calendar's default season — with the other timeslices' forward and
reverse limits emitted as per-unit values in a link_timeslice_limits
table for pypsa_build to expand into p_max_pu/p_min_pu series (per-unit
values can exceed 1.0 when a season's limit tops winter's). Asymmetric
reverse limits land in p_min_pu rather than separate links.

Expansion options become one extendable link per (path, investment
period) with annuitised capital costs, gated by transmission_expansion /
rez_transmission_expansion according to whether the path connects a REZ.
Paths with no capacity data take
rez_to_sub_region_transmission_default_limit, replicating the existing
REZ behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
src/ispypsa/translator/helpers.py 100.00% <100.00%> (ø)
src/ispypsa/translator/network.py 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

nick-gorman and others added 2 commits July 2, 2026 09:57
Pilots the blank-key-as-wildcard convention on the three sparse network
tables (limits, expansion options, expansion costs) as a reference
implementation for wider adoption: _resolve_wildcards in
translator/helpers.py does the resolution until schema validation lands,
with runtime guards in network.py standing in for the option-pairing
rule. Drops that are designed selection (constraint_relaxation routing,
non-investment-period cost years) are no longer logged as data loss.

Links now take p_nom as the larger of their two directional capacities
so every per-unit limit sits in [-1, 1], and all real limits (including
blank-timeslice fallbacks) move into link_timeslice_limits under the
coverage contract (#123); the static link attributes are
inert defaults.

rez_to_sub_region_transmission_default_limit becomes
transmission_default_limit -- the default now applies to any path
without limit data, not just REZ connections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The options/costs pair now has all its empty/populated combinations
pinned -- including options-without-costs, which the schema declares
invalid but which silently builds no expansion links until schema
validation lands -- plus the no-expansion-study config (both flags off),
which resolves wildcards against an empty allowed set.

The two cost-wildcard behaviours documented in _prepare_expansion_costs
but previously untested are exercised: a concrete-year row overriding a
blank-year static row (also the one spot where wildcard-expanded int
years concat with CSV-parsed float years before the int cast), and a
blank expansion_id acting as a table-wide default cost.

The limits drop log gets its firing case (the negative already existed),
and the calendar-year NotImplementedError closes the last uncovered line
in network.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nick-gorman nick-gorman changed the base branch from main to rename-transmission-default-limit July 2, 2026 03:12
nick-gorman and others added 3 commits July 2, 2026 13:13
The name was a leftover from the winter-reference design, where the
frame really did hold the link-level static limit; it now holds the
per-direction maximum capacities that _extract_max_capacities produces,
so the old name misdescribed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ndings

_resolve_wildcards previously dropped out-of-set values itself, logging or
staying silent per an expected_drops escape hatch, which conflated designed
config-driven selection with bad input data. Callers now filter their designed
selections out first (logged at INFO) and the resolver raises on anything left
outside the allowed set — for limits a typo'd path_id halts the run, while an
options/costs typo is indistinguishable from a constraint group or disabled
element, so the log line is its only trace.

Also acts on the PR #126 review: the forward/reverse pairing guard counts a
blank expansion_option as its own label (nunique dropna=False) so a
blank/named mismatch raises; the expansion_option schema description no
longer claims the column is informational-only; p_nom collapses to a single
groupby max; annuitisation is vectorised; and name-list test assertions are
replaced with full-frame comparisons. Expansion cost years are opaque labels
matched against the config's investment periods — year_type only governs how
time is chunked — so the calendar-year NotImplementedError guard is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nick-gorman nick-gorman requested a review from EllieKallmier July 3, 2026 05:21
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.

1 participant