Skip to content

Add custom-constraints-from-PLEXOS templater#112

Open
nick-gorman wants to merge 14 commits into
mainfrom
new-manually-extracted-tables
Open

Add custom-constraints-from-PLEXOS templater#112
nick-gorman wants to merge 14 commits into
mainfrom
new-manually-extracted-tables

Conversation

@nick-gorman

@nick-gorman nick-gorman commented May 28, 2026

Copy link
Copy Markdown
Member

What this does

ISPyPSA needs AEMO's REZ / sub-region export limits as custom constraints, but
they don't come out of the IASR workbook in a usable shape. AEMO does express
them in its PLEXOS model, as export-group constraints, so we extract them from
there. This PR adds the templater that turns that extract into ISPyPSA's three
custom-constraint tables:

  • custom_constraints: one row per constraint (id + direction)
  • custom_constraints_lhs: the left-hand-side terms (which unit, what coefficient)
  • custom_constraints_rhs: the per-timeslice limit on the right

It sits behind the use_new_table_format flag (committed off) and only runs at
sub_regions granularity — coarser granularities collapse the sub-region
nodes, sub-regional flow paths and REZ-located units the constraints reference,
leaving nothing to constrain.

What the translation looks like

One constraint end-to-end, based on the e2e test fixture. From the PLEXOS
extract (lhs_terms.csv, abbreviated — constraints.csv carries SWQLD1's
Sense of -1, rhs_values.csv its limit of 3000 MW tagged "QLD Hot Day"):

constraint_name     parent_class  parent_name                  property                 value
ExportGroup_SWQLD1  Generator     BW01                         Generation Sent Out C.    0.5
ExportGroup_SWQLD1  Generator     Q8_SAT_Brisbane              Generation Sent Out C.    0.4
ExportGroup_SWQLD1  Generator     SWQLD1_Linear Augmentation   Installed Capacity C.    -1.0  # relaxation term: dropped
ExportGroup_SWQLD1  Battery       SWQLD1 Battery - 2h          Generation Coefficient    1.0  # constraint-scoped: dropped, pass 2 re-supplies
ExportGroup_SWQLD1  Battery       SWQLD1 Battery - 2h          Load Coefficient         -1.0  # negative pair: dropped
ExportGroup_SWQLD1  Line          NSW1-QLD1                    Flow Coefficient          0.8
ExportGroup_SWQLD1  Purchaser     Q1 to NQ Flex. Electrolyser  Load Coefficient         -1.0  # hydrogen: dropped

the templater produces:

custom_constraints:
constraint_id  direction
SWQLD1         <=                                              # prefix stripped; Sense -1 -> <=

custom_constraints_lhs:
constraint_id  term_type         variable_name    coefficient  date_from
SWQLD1         generator_output  BW01             0.5
SWQLD1         generator_output  Q8_SAT_Brisbane  0.4
SWQLD1         link_flow         NSW-QLD          0.8          # PLEXOS line name -> path_id
SWQLD1         storage_output    Q8 Battery - 2h  1.0          # pass 2: Q8_SAT_Brisbane triggers REZ Q8
SWQLD1         storage_output    Q8 Battery - 4h  1.0          # incl. the 4h duration PLEXOS omits

custom_constraints_rhs:
constraint_id  timeslice        rhs     date_from
SWQLD1         qld_peak_demand  3000.0                         # "QLD Hot Day" -> region-prefixed timeslice

Note the RHS timeslice: it comes out region-prefixed (qld_peak_demand)
rather than bare (peak_demand) like the rest of the templater's outputs. A
follow-up PR will bring the rest of the transmission functionality onto this
format so the constraints flow through end-to-end.

The bit worth understanding first: batteries

The RHS and most of the LHS are a straight translation from the PLEXOS
extract. The one judgement call is batteries.

PLEXOS encodes battery participation as per-constraint LP variables (the
SWQLD1 Battery - 2h rows in the example above) whose layout doesn't map onto
any IASR unit (see #110), and it quietly leaves out the 4h
durations. Rather than mirror that, the templater builds the LHS in two
passes:

  1. Translate. Convert the raw PLEXOS LHS rows (generators, batteries,
    lines, nodes) into ISPyPSA's term schema. Unit names that don't resolve to
    an IASR unit are dropped — which removes the constraint-scoped battery
    variants by design.
  2. Re-inject. For each constraint, find the REZs / sub-regions where its
    surviving new-entrant units (generators or batteries) sit, and add a
    storage_output term for every IASR new-entrant battery at those
    locations. Each injected battery copies the (date_from, coefficient)
    profile of the new-entrant batteries that survived pass 1 there, so an
    injected 4h battery lands at the same weight as its 2h/8h siblings (e.g.
    0.43 at SWQLD1's Q8) rather than a flat 1.0. Where no sibling survived, it
    falls back to 1.0 and a WARNING names the (constraint, location) so the
    default can be audited.

The copy is only well-defined if a location's surviving batteries agree, so
the templater raises if they don't share one profile.

More generally, the templater fails loud on anything it doesn't recognise: an
unmapped (parent_class, property) pair or Sense value, an unknown PLEXOS
line name, a battery Load Coefficient row missing its Generation Coefficient pair, or a populated date_to. Deliberate drops are logged —
INFO where they're by-design (Purchaser electrolysers, constraint-relaxation
terms, battery Load pairs), WARNING where the drop loosens a constraint (units
with no ISPyPSA counterpart).

Where it lives

ISPyPSA/
├─ src/ispypsa/templater/
│  ├─ custom_constraints_from_plexos.py   the templater (PLEXOS extract → the 3 tables)
│  ├─ plexos/7.5/*.csv                    the extract it reads at runtime (already on main)
│  ├─ create_template.py                  wires it in at sub_regions; tracks the 3 outputs
│  └─ mappings.py, transmission.py        now share one canonical-timeslice vocabulary
├─ scripts/
│  ├─ extract_plexos_constraints.py       PLEXOS XML → the extract above (already on main)
│  └─ workbook_extract_constraint.py      independent workbook-route LHS, validation only
└─ tests/test_templater/
   ├─ test_custom_constraints_from_plexos.py   templater unit tests
   ├─ test_custom_constraints_validation.py    PLEXOS route vs workbook route reconciliation
   └─ data/custom_constraints_validation/      fixtures (plexos/ + workbook/)

Supporting changes

The templater is wired into create_ispypsa_inputs_template (gated to
sub_regions, its three outputs tracked as task targets); the two IASR summary
tables it needs are added to the required-tables list; and the canonical
timeslice vocabulary is promoted into mappings so this templater and the
existing transmission code share one copy.

Validation

Pulling these constraints out of PLEXOS takes enough translation that we
didn't want to take the output on trust, so the PR cross-checks the templater
against a second, fully independent reconstruction of each constraint built
straight from the IASR workbook. The two routes share no code:

  • The PLEXOS route is the production templater reading the PLEXOS extract.
  • The workbook route (scripts/workbook_extract_constraint.py) looks each
    constraint up in rez_group_constraint_summary, parses its Terms into
    (coefficient, body) pairs, and expands them: a REZ id (e.g. Q1) becomes
    every generator, battery and electrolyser tagged with that REZ ID across the
    IASR unit summary tables; an interconnector path id (e.g. CQ-NQ) becomes a
    single link_flow term. Coefficients can be implicit (Q1 is 1.0), signed
    (- CQ-NQ is -1.0) or explicit (0.78 * V7).

test_custom_constraints_validation.py runs both routes over five constraints
(NQ1, NET1, WV1, CQ1, MN1) and asserts the LHS term sets match
exactly, save for an explicit EXPECTED_DELTAS table. A new mismatch in
either direction fails the test, and so does an expected one going missing.

The documented differences come down to two things:

  1. Geographic lookup for existing plant. Every IASR unit carries both a
    fine-grained REZ ID and a coarser sub-region, and they usually agree. The
    workbook route expands by REZ ID; PLEXOS includes existing plant by
    sub-region. The handful of boundary plants where the two tags point
    different places are the only generator diffs — e.g. EMERASF1 (REZ Q4,
    sub-region NQ) lands in NQ1 under PLEXOS but CQ1 under the workbook,
    so the one fact shows up on both sides of the comparison.
  2. Electrolysers. PLEXOS routes electrolyser load through its Purchaser
    class, which the templater drops by design (hydrogen demand isn't
    modelled), so they appear as workbook-only load terms.

Batteries don't surface as a difference: the five validated constraints are
all unit-coefficient, so both routes land the same batteries at 1.0. The
pass-2 coefficient copy only bites on the fractional constraints (SWQLD1,
WNV1, NSA1, SQ1), which the workbook route can't reach — those rely on the
per-module unit tests instead.

@nick-gorman nick-gorman force-pushed the new-manually-extracted-tables branch from 5895ffe to 91d060b Compare June 3, 2026 02:01
@codecov

codecov Bot commented Jun 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
src/ispypsa/iasr_table_caching/local_cache.py 74.28% <ø> (ø)
src/ispypsa/templater/create_template.py 92.77% <100.00%> (+3.16%) ⬆️
...spypsa/templater/custom_constraints_from_plexos.py 100.00% <100.00%> (ø)
src/ispypsa/templater/mappings.py 100.00% <100.00%> (ø)
src/ispypsa/templater/transmission.py 98.70% <100.00%> (-0.01%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

nick-gorman and others added 13 commits June 10, 2026 09:59
Splits the constraint definition (id + direction) from RHS values (per
timeslice) and LHS terms. Membership and coefficients are taken from the
PLEXOS constraint formulation (data/plexos/), with the IASR workbook
"Build limits - REZs" tab as the cross-reference for narrative context.

Hydrogen constraints are excluded as ISPyPSA does not currently support
electrolyser load.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both the parallel-path expansion rows and the new custom-constraints
templater need the same representative timeslice vocabulary
(peak_demand, summer_typical, winter_reference). Promote it to a single
_CANONICAL_TIMESLICES constant in mappings so the two stay in lockstep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Translates the PLEXOS extract into ISPyPSA's three custom-constraint
tables in two passes: a literal PLEXOS->ISPyPSA translation, then a
"common sense" injection of IASR new-entrant batteries for every
REZ/sub-region whose generators participate in a constraint. PLEXOS' own
battery participation is an opaque LP-variable layout we deliberately
don't mirror (see #110 and #111). Ships a
workbook-vs-PLEXOS validation harness and caches the two IASR
generator-summary tables the templater depends on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splice the three custom-constraint tables into the new-format template,
gated to sub_regions granularity: the constraints reference sub-region
nodes, sub-regional flow paths and REZ-located units that have no
meaningful representation once sub-regions are collapsed. Threads
iasr_workbook_version through the orchestrator and its callers to select
the PLEXOS extract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
notes/ holds local-only exploration scripts and drafts that should never
be committed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comments and docs must not point at gitignored paths (e.g. notes/);
reference a GitHub issue instead. Codifies the convention so future
contributions don't create dead links.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The PLEXOS extract script is now shared by the custom-constraints templater, where this coefficient appears as a constraint relaxation variable rather than a network-expansion decision variable. Re-applies the wording lost when the duplicate extract commit was dropped during the rebase onto main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The custom-constraints templater adds new_entrants_summary and existing_committed_anticipated_additional_generator_summary to the new-format required-tables list, so the create_ispypsa_inputs doit task now declares a file dependency on them. main's frozen-7.5-cache CLI tests (test_create_ispypsa_inputs_new_table_formats.py) failed the dependency check because these two tables weren't in the committed cache. Copied from the real parsed 7.5 workbook tables (byte-identical provenance to the other 80 cache files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create_ispypsa_inputs writes the custom-constraint tables at sub_regions (write_csvs is dict-driven), but list_templater_output_files only listed the five network tables, so the doit task never declared them as targets. They were written-but-untracked: deleting one would not trigger a rebuild, and downstream tasks consuming get_ispypsa_input_files() did not see them as dependencies. Make the new-format output list granularity-aware so the three tables are declared only at sub_regions, matching the templater's own gate (and never expected at coarser granularities, where they are not written).

Adds a unit test pinning the granularity-aware output list and extends the new-format CLI test to assert the tables are written (no orphan LHS/RHS rows) at sub_regions and absent otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Locate the shipped extract with files("ispypsa.templater") rather than Path(__file__).parent, matching the resource lookup already used in local_cache.py and not relying on __file__.

Add a direct test for _plexos_extract_dir. The default-path branch was previously only reached through the create_ispypsa_inputs CLI, which runs in a subprocess and so never showed as covered; the new test exercises it in-process and guards that the three extract CSVs ship with the package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The new-format path generates the three custom-constraint tables from the PLEXOS extract and returns before the only line that splices in manually_extracted_tables (create_template.py), so these hand-extracted 7.5 custom_constraints / _lhs / _rhs files were loaded by load_manually_extracted_tables and then ignored. They were a precursor the PLEXOS templater superseded. Full suite passes without them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pass 2 re-adds the new-entrant batteries PLEXOS omits from an export
constraint (chiefly the 4h durations) and previously gave them a flat
coefficient of 1.0. But PLEXOS applies a per-unit export-loss factor, so an
omitted 4h battery landed at 1.0 while its 2h/8h siblings at the same REZ
carried the real fractional value (e.g. 0.43 at SWQLD1's Q8) -- overstating
its weight in the constraint LHS across SWQLD1, NSA1, WNV1, SQ1 and CNSW-SNW.
SQ1 was worse: the 1.0 rows coexisted with the real time-varying 0.15 rows
because their date_from differed.

Copy each injected battery's coefficient (and its time-varying date_from
rows) from the new-entrant batteries that survived pass 1 at the same
location, falling back to 1.0 only where none survived. A ValueError guards
the load-bearing assumption that those siblings agree on coefficient, and a
WARNING surfaces any location that fell back to the default.

The five workbook-validated constraints are all unit-coefficient, so they are
unchanged; the fractional constraints the workbook route can't reach are
covered by new per-module tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reconciliation test's module docstring claimed the templater "discards
[PLEXOS' battery participation] and injects the full set" of new-entrant
batteries. That understated pass 1, which keeps the named PLEXOS batteries at
their real (often fractional) coefficients; pass 2 only re-adds the durations
PLEXOS omits, copying the surviving siblings' coefficient. Update the docstring
to match, and note that the coefficient-copy only bites on the fractional
constraints this workbook cross-check can't reach.

Docstring only -- no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nick-gorman nick-gorman force-pushed the new-manually-extracted-tables branch from 14f1a7b to 939f346 Compare June 10, 2026 00:07
Review hardening for the custom-constraints templater. Three silent
failure modes now raise or log instead of quietly degrading the
constraint set, and pass-2 battery injection no longer depends on a
generator surviving:

- A battery Load Coefficient row with no Generation Coefficient pair
  raises: dropping it would delete the battery's only LHS term and
  loosen the constraint, not remove a redundant negative pair. The
  routine pair-drop is now logged at INFO as the module docstring
  already promised.
- A Sense value outside {-1, 0, 1} raises instead of passing through
  as a NaN direction, consistent with unmapped (parent_class,
  property) pairs.
- The surviving-sibling consistency check compares whole (date_from,
  coefficient) profiles per (constraint, location), not just
  coefficients at shared dates. A time-varying sibling's extra dated
  rows would otherwise be grafted onto its constant siblings silently,
  because the injected extra-date rows survive deduping.
- Surviving new-entrant batteries now trigger the pass-2 injection
  alongside generators, so a location PLEXOS includes via batteries
  alone still gets its omitted durations (e.g. 4h) re-added.

The workbook-vs-PLEXOS validation suite is unchanged by the trigger
extension: all five validated constraints already triggered via
generators.

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

@EllieKallmier EllieKallmier left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this looks good! My main comments/thoughts on this PR are mostly around my own confusion with some of the descriptions of stuff and phrasing used - hopefully my comments below clarify but ofc happy to discuss more.

The two-pass approach makes sense, and matches up with behaviour elsewhere re: essentially merging in the 'missing' rows where there's some indication that they should exist (and no indication that they explicitly shouldn't).

I don't think any of my comments should stop this PR from merging - I'm hoping I've understood at least the main vibes of what's going on and based on that understanding this work seems solid :)

@@ -0,0 +1,277 @@
"""Manually extract a rez-group custom constraint from the IASR workbook.

This is an independent reconstruction of one custom constraint, built directly

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

To clarify/summarise - this is a helper script whose output is just to print the LHS unit rows processed for the given constraint ID?

# sub-region nodes, sub-regional flow paths and REZ-located units the constraints
# reference, leaving nothing to constrain. Listed as outputs only at that
# granularity so the create_ispypsa_inputs task tracks them where they are
# written and does not expect them where they never are.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This I guess supports an approach to regional granularity (for the moment) where something stops users from actually running any granularity other than subregional (e.g. feature flags).

Also was just having a look back at this issue re: naming conventions and I think "subregions" (no hyphen/underscore) was the decision there for node naming - absolutely the most minor comment and I have not been paying attention to this much myself but just chucking here in case we want to chat/change/do anything in relation to how we refer to sub(-)regions in other contexts?

# token of generator names (``DN1_SAT_Dubbo`` -> ``DREZ_SAT_Dubbo``) and to
# battery names of the form ``"DN1 <location> Battery - <duration>"``
# (``DN1 Dubbo Battery - 2h`` -> ``DREZ Dubbo Battery - 2h``).
_GENERATOR_PREFIX_RENAME = {"DN1": "DREZ", "DN3": "DREZ"}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Here and for batteries below - why no DN2? (Guessing this DREZ isn't present in the plexos constraints?)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this file is where that thing you mentioned in the meeting last week about phrasing getting a little confusing/maybe odd is coming through for me. Some of the words ('trigger', 'surviving') do make sense and aren't not understandable, but maybe could be introduced or defined a bit more specifically before going on to be used kind of ubiquitously?

Just feeling a bit like sometimes adding a few extra words or something could make some of the docstrings a little easier to intuit - but of course this is a personal take and appreciate is a bit in the weeds!

To give an example, this sentence from one of the docstrings:

Surviving batteries trigger as well as generators so a location
whose generators were all dropped (or that PLEXOS includes via batteries
alone) still gets its omitted durations re-added.

is kind of isolated from previous introduction of the 'surviving' and 'trigger' terms; adding a few extra words to contextualise is prob my instinct from a clarity POV, e.g. "Surviving batteries trigger the creation of additional rows even in locations whose generators were all dropped ...".

This is not a big deal and I was able to wrap my head around this file in the end but yeah I think the use of some of these terms or phrases that have a kind of implied context-specific meaning just added some extra time for me in unpicking it. Again - prob a person-by-person thing so no change required from me, just feeding back on what we chatted about the other day!


These are PLEXOS units with no ISPyPSA counterpart -- e.g. CER units not
yet modelled (Open-ISP/ISPyPSA#104) or plant absent from the IASR tables.
Dropping loosens the constraint, so it is logged as a WARNING.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No real comment just some out-loud thinking for me to reflect on:

Makes sense to drop and log as warning here, and/but kinda relates to the canonicalisation question you raised in #107 and our discussions around what we expect the templater input to look like. Basically I'm wondering if (as you posited in a comment on that pr) it might make more sense or be simpler to just drop/warn/not raise on non-matches and let the validator raise 'real' problems later (i.e. not just 'not implemented yet' kinda things)... in some cases

constraint_id location coefficient date_from
SWQLD1 Q8 0.43
WNV1 V7 0.78
WNV1 V7 0.00 2031-11-30

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I am getting a little bit mixed up with some of the wording here and what the _raise_on_inconsistent_battery_profiles function below actually checks for 'inconsistency' - very possible I'm missing something about constraint definitions so grain of salt!

Specifically:

  • "the surviving new-entrant batteries must share one (date_from, coefficient) profile" -> reads to me like saying only one (date_from, coefficient) tuple is allowed for each (constraint, location) tuple - in which case I think I assume that the I/O example should raise for the (WNV1, V7) pair?
  • "a time-varying sibling whose extra date_from rows would otherwise be grafted onto its constant siblings silently" (below) -> describes a failure model -> to me reads like it's saying only either time varying or constant (but not both) pairs allowed, which again the I/O example here would raise (?)

From the tests and the code itself I can see this is not the correct interpretation - think maybe these two docstrings and/or example here could be edited a little to improve clarity?

result = _battery_rows_for_triggers(triggered, batteries_by_location, coefficients)

# Both Q8 batteries inherit 0.43; the V7 battery is emitted once per profile
# row (0.78 always, 0.0 from 2031-11-30); Q1's battery defaults to 1.0.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm a bit confused by the phrasing "0.78 always, 0.0 from date" - possibly just not understanding constraint formulation but "always" then "from x" seem contradictory? Or jsut not very clear - like "0.78 up to date, 0.0 from date" would be more obvious to me (related to earlier comment/confusion around this function's definition) - but perhaps I've also got an incorrect interpretation of what these values mean

_surviving_battery_coefficients(lhs, new_entrants)


def test_surviving_battery_coefficients_raises_on_divergent_date_sets(csv_str_to_df):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ok I think this explains this function a bunch more for me - I understand the vibe here!

Still though think there could be some clarification within the docstrings/examples to make this a bit more intuitive or obvious there?

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