Skip to content

Translate the new-format custom constraints, with relaxation and expansion-limit constraints - #139

Merged
nick-gorman merged 29 commits into
mainfrom
translate-custom-constraints
Oct 22, 2026
Merged

nick-gorman merged 29 commits into
mainfrom
translate-custom-constraints

Conversation

@nick-gorman

@nick-gorman nick-gorman commented Aug 18, 2026 •

Copy link
Copy Markdown
Member

Translate the new-format custom constraints, with relaxation and expansion-limit constraints

  • Custom constraints are reformated to pypsa friendly format.
    • Including from_date functionality which allows lhs coefficients and rhs values to change with time.
    • Timeslice references are added so rhs can change with active timeslice
    • Constraint variable name mapping to pypsa variable names. Assuming that tables are provided mapping new entrant generator names like "CNSW CCGT" to "CNSW CCGT_2026" and "CNSW CCGT_2028". The column names of this table are open to change and the exact name format doesn't affect the mapping, but docstrings examples can be updated to what the names will actually look like.
  • Add in dummy generators to allow constraint relaxation
  • Add in link and constraint relaxation limit constraint, i.e. capping the total transmission expansion over all model years.

nick-gorman and others added 2 commits August 18, 2026 13:36
Each RHS value becomes one constraint instance per investment period
(and per timeslice where tagged), with date_from resolved as the value
active at the start of each period. LHS terms keep generator and battery
names even though those components aren't translated yet — pypsa_build
skips-and-logs missing components, and the logging silences itself once
generator translation lands. The generators/batteries parameters are
accepted now as the future name-mapping hook.

Constraint-relaxation expansion options become dummy generators on
bus_for_custom_constraint_gens, with their LHS terms filtered per period
by build_year so a generator built for 2040 can't relax a 2030
constraint instance — an improvement over the old single-constraint
formulation, which couldn't express this. Extendable links and
relaxation generators get "_expansion_limit" constraints capping total
p_nom at the option's allowed expansion; the suffix keeps their names
clear of the parent constraints'.

Duplicate input rows and RHS rows without LHS pairs raise rather than
collapse silently; the "load" term type maps to Load/p, which
pypsa_build logs as unimplemented and skips, matching the old path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#126 changed the expansion tables to allow blank key cells as wildcards, changed
_prepare_expansion_costs to take the enabled element ids, and made expansion links
carry forward/reverse per unit of max(forward, reverse). The cherry-picked module
still read unresolved options, capped paths at their forward capacity and imported a
helper #126 deleted.

The expansion-limit cap is now max(forward, reverse), re-resolved for the paths that
have expansion links with the network module's own helpers, and constraint
relaxations resolve their options and costs against the constraints in the model so
blank-id defaults apply to every constraint.

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

codecov Bot commented Aug 18, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
...spypsa/templater/custom_constraints_from_plexos.py 100.00% <ø> (ø)
src/ispypsa/translator/constraints.py 100.00% <100.00%> (ø)
src/ispypsa/translator/mappings.py 100.00% <ø> (ø)
src/ispypsa/translator/network.py 100.00% <100.00%> (ø)

... and 10 files with indirect coverage changes

🚀 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 August 18, 2026 15:19
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…raint docstrings

_keep_rows_for_enabled_elements is shared by the network and constraints
translators, but "enabled elements" only described the network caller — the
constraints side passes constraint_ids. Renamed to _keep_rows_for_expansion_ids
and reworded the docstrings so each caller's selection is described where the
decision is made. The constraint helpers' I/O examples now show full tables,
including the blank-cell wildcard forms the options and costs accept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nick-gorman and others added 16 commits August 25, 2026 10:47
A relaxation generator's p_nom was always subtracted from the parent
constraint's LHS, which loosens a "<=" but tightens a ">=". The sign now
follows the constraint's direction (-1.0 for "<=", +1.0 for ">=") so
building relaxation capacity always loosens the constraint; an option on
an "=" is rejected, since no single-signed term can loosen an equality.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXjsJKX7ANSJxVyJbszLYW
… periods

The translator raised on duplicate rows, missing directions, unpaired
constraints and relaxed equalities -- all properties of the input tables,
so they're now declared as schema rules the translator trusts without
re-checking. What's genuinely per-run remains: after date_from resolution
a (constraint, period) can end up with terms on one side only, so those
periods are dropped from both sides with an INFO line each way.
Relaxation-generator terms derive from the post-drop RHS so they can't
re-create one-sided periods.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXjsJKX7ANSJxVyJbszLYW
…nerators

Every option has a cost in every investment period (the costs schema's
coverage rule), so joining options to costs gives exactly one generator
per option and period. The test fixture violated that rule -- SWQLD1 had
a 2026 cost only -- which hid the second per-period generator from every
relaxation expectation; it now carries schema-valid costs and the tests
exercise both SWQLD1_exp_2026 and SWQLD1_exp_2028.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXjsJKX7ANSJxVyJbszLYW
Terms naming components not in the model were described as skipped with
a log line downstream -- a silent per-term drop. The
custom_constraints_lhs schema now ties each term_type's variable_name to
the table it must exist in, so the translator and pypsa_build take the
names as given. The templater TODO points at the rule as the end state
for its IASR ID lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXjsJKX7ANSJxVyJbszLYW
A blank timeslice isn't "binds at every snapshot": it's the constraint's
fallback row, binding only at the snapshots none of its named-timeslice
rows cover, matching the path-limits convention. Resolving timeslices to
snapshots stays pypsa_build's job; this module passes them through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXjsJKX7ANSJxVyJbszLYW
Set-membership probes like "SWQLD1_exp_2026 not in variable_name" pass
for many wrong outputs. The relaxation-disabled, late-date_from and
missing-link tests now compare whole expected frames, and the negative
log case covers every INFO drop line the module can emit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXjsJKX7ANSJxVyJbszLYW
allowed_expansion rode through the relaxation-generator pipeline without
any step using it, only to be read off at the end. The orchestrator now
passes the resolved options' caps directly to
_create_expansion_limit_constraints, the rez_transmission_expansion gate
moves into _resolve_relaxation_options where the decision is made, and
_finalise_generators disappears.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXjsJKX7ANSJxVyJbszLYW
The module docstring and the orchestrator's docstring had become two
accounts of the same pipeline, with fixes having to land in both. The
orchestrator docstring now carries the lot -- the translation steps, the
relaxation and expansion-limit behaviour, the schema rules the pipeline
trusts without re-checking, and the blank-timeslice fallback semantics --
next to the I/O example, and the module docstring is deleted. Accuracy
fixes folded in along the way: the one-sided period drop names both of
its causes (date_from coverage and model scope), the flow-path cap is
stated as max(forward, reverse), link_flow expansion is described as
replacing the path_id term with per-link terms, and the
rez_transmission_expansion gate, per-option-per-period generators and
annuitised costs are now stated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXjsJKX7ANSJxVyJbszLYW
The side-by-side table pairs and the compressed links one-liner in the
constraints I/O examples saved vertical space at the cost of scanning --
every input table now sits sequentially in the same tabular format as
the rest of the examples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXjsJKX7ANSJxVyJbszLYW
…trator

Dropping a term whose component isn't in the model left the constraint
applied with a weakened LHS -- plausible-looking and silently wrong. The
translator now raises when any LHS term fails to resolve to a model
component (load terms, which pypsa_build doesn't implement, raise too),
and resolves every term through an isp_name -> name mapping supplied by
new generators and storage inputs, so new entrant units expand to their
per-build-year components the same way link terms expand over a path's
links. The generator/storage frame contract (isp_name + name, mirroring
links) is assumed ahead of the translators that will produce it, for
review by the team building that side.

The orchestrator is renamed _translate_custom_constraints (the
constraints aren't network-specific any more) and split into three block
producers -- translate the user-authored tables, create the constraint
relaxations, create the expansion limits -- assembled and finalised once,
so future producers of constraint blocks (e.g. new entrant build limits)
have a seam to append into before relaxation resolves. The reusable
pieces' docstrings now state their source-agnostic contracts rather than
relaxation-specific vocabulary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXjsJKX7ANSJxVyJbszLYW
The blanket raise on load terms made the real constraint set
untranslatable -- the baseline CNSW-SNW South GPG constraint carries
date_from-varying load terms. Demand is exogenous (p_set on the Load
components pypsa_build attaches per demand node), so a load term is a
data term, not a variable term: it now resolves through the standard
isp_name -> name mapping to the "load_<bus>" component with attribute
p_set, ready for pypsa_build to fold coefficient x demand into the
constraint. pypsa_build owes that implementation and should raise, not
skip, until it lands.

Validation is against a new demand_nodes input rather than all buses,
because REZ buses carry no demand and which buses do follows the
regional granularity -- a load term for a sub-region the granularity
aggregates away raises through the same not-in-model check as link
terms. Also labels the missing-component error's tuple structure as
(constraint_id, variable_name).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXjsJKX7ANSJxVyJbszLYW
…l_name

The mapping's inherited isp_name/name columns said where the data came from,
not what the mapping means, so the constraints module now names both sides
for their role. The custom_constraints_lhs schema also sets out the
convention those names imply: a variable_name refers to a whole element -
a new entrant across all its build years, a path across its existing and
expansion links - never a per-build-year component.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182yKiTKCwGoVPs5hToavKn
…trings

Condensed column=value examples made the reader reconstruct the table shape
in their head, so the I/O Example convention now requires full tables (with
abridged column/row sets for otherwise large ones) and the constraints
module's remaining condensed examples follow it. The expansion example also
gains a new entrant term, showing the per-build-year fan-out next to the
link and existing-unit cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182yKiTKCwGoVPs5hToavKn
…nches

Coverage showed four untested paths: the all-empty assembly branch, calendar
year period starts, the unmapped-term_type drift guard, and the duplicate
RHS name check. The last needed the one realistic collision - a relaxable
constraint sharing its ID with an expandable path, giving both the same
expansion-limit cap row - which the new test pins down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182yKiTKCwGoVPs5hToavKn
A component's p_nom is a single horizon-wide variable, so a term on a
not-yet-built or already-retired component would let capacity from
outside a period alter that period's constraint (dispatch terms were
harmless, since PyPSA zeroes inactive dispatch, but made the output
misleading). Terms are now kept for exactly the periods PyPSA activates
the component - build_year <= period < build_year + lifetime, with the
retirement year derived from the lifetime the translated tables already
carry - so the constraints can't disagree with the model's own
activity mask.

Also pins the schema-legal edge cases the suite didn't cover:
blank-timeslice RHS fallbacks, values dated exactly on a period start,
mid-horizon coefficient supersession, generator_capacity terms,
new-entrant storage expansion, wildcard relaxation options, and
populated constraints alongside empty expansion tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DBECPsKWwUCxESCcU2Ykxk
The forward-quantisation semantics - date_from, build_year and lifetime
all rounding onto the investment-period sequence the way PyPSA's
multi-investment-period activity mask does - were described on the
top-level orchestrator, away from the function that implements them and
duplicated across its step bullets. State the rule once on
_translate_constraint_tables, collapse the bullets that restated it,
and leave the orchestrator a pointer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DBECPsKWwUCxESCcU2Ykxk

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

Hey Nick! Took me a minute to go through all this and wrap my head around it all but I think I've got there, and this seems pretty solid to me. A few comments, some questions for my sake, and some extremely minor line-wrapping suggestions left throughout.

Probably the biggest functional piece that I'm not 100% sure about is the wild-card filling for the no-date_from rows - there's a comment in place but I'm not sure how clear my question actually is there lol. The summary q/uncertainty from me I guess is just about whether it's relying on an expectation of input data that if there's at least one row for a given group that has a date_from value, any constraint values that should be applied to periods after that defined one will also be defined with a date_from value.

To be clear though - I don't think that's a problem, just not sure whether that's the full intent/expectation, and if so it might be helpful to have that clarified in place?

Otherwise - I don't have any problems with this, and it feels like a relatively easy flow to edit over time if we need to. I'd be interested to hear your reservations cause I think you have a better sense for the bigger picture surrounding this chunk of work and seemed like you weren't totally sold, but yeah I'm happy with this for the moment :)

tables currently also matches DER/CER rows that ISPyPSA may not template.
The custom_constraints_lhs schema's variable_names_resolve_by_term_type rule
declares that end state; note the new-entrant templater renames units to
"<geo_id> <technology>", which the lookup here will need to follow.

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.

with the update from #144 now merged in this isn't the case anymore - hoping that makes things a little easier. Still though names can be changed for non-subregion granularity so yah worth keeping an eye on

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Pardon, I think this was a stale comment anyway as my plan is to not template custom constraints for non-sub-regional granularities. A follow up PR will address the rest of this todo so the constarint templating uses the templated generator and storage tables rather than the raw ones.

Comment thread src/ispypsa/translator/constraints.py Resolved
Comment thread src/ispypsa/translator/constraints.py Resolved
@@ -0,0 +1,1261 @@
import logging

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.

not an action item, just a style kinda question - no module-level docstring for this one? I don't necessarily have a deep attachment to having them just thinking about internal style consistency (how much do we really care? not too much atm I think) and wondering whether you have strong feelings either way

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.

with addition that the orchestrator func docstrings in this module essentially play the same role

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, that was my thought, the func doc string seemed to cover it. I guess at the moment I've been making judgement calls and adding module level docstrings if I think there is too much worth documenting to fit into the orchestrator docstring.

Comment thread src/ispypsa/translator/constraints.py Resolved
SWQLD1 qld_peak_demand 2500 2035 # 2032 value held from period start
"""
table = table.copy()
table["date_from"] = pd.to_datetime(table["date_from"]).fillna(pd.Timestamp.min)

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 confirm: because there's no 'date_to' equivalent value for these tables, the 'resolved' active rows just extend on forever from the date_from value unless/until a row with a later date_from value appears (for the same group columns)?

Just thinking through the wild-card thing and how it can't be used here to implicitly fill out rows that start after any 'real' date_from values (that have the same grouping values ofc). I'm struggling to put my question into clear words atm so this might be a bit loose - but am I understanding that right? That it's always a case of 'latest value wins', and a no-date_from row can never win that unless it's the only row for that group?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've updated custom_constraints_lhs.yaml and custom_constraints_rhs.yaml to document this better. As we chatted about date_from are joined on investment_period in an as_of fashion, so they apply untill overwritten by a more recent on. If date_from is blank, those values would just apply before any date_from values became active. So the wildcarding works as for other tables, just with merge_as_of logic.

Comment thread src/ispypsa/translator/constraints.py Outdated
Comment on lines +768 to +769
lhs without its NQ1 2028 term (no RHS row that period; logged)
rhs without its SWQLD1 2026 row (no LHS terms that period; logged)

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.

It hink this could still be written out as tables, even though it is fairly self-explanatory. Just cause the returned tables would be pretty small but just immediately descriptive

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed

Comment thread src/ispypsa/translator/constraints.py Resolved
applied.
variable_name uses IASR IDs for generators and batteries, path_ids for links
and sub-region codes for loads. The variable_names_resolve_by_term_type rule
ties each to its component table, so the translator and pypsa_build take the

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.

Is this (custom validation rule) meaningfully different in function to the _raise_on_terms_not_in_model guard in constraints.py? My understanding of the difference between them atm is that the raise func is checking for existence in the model anywhere, and this validation rule is checking for presence specifically linked to the term type (pypsa component?). Unless I'm mixing something up here?

Not any issue on my end, just parsing out where the different validation/checking/guards are coming in. Developing the mental flow chart :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good question. Firstly, that comment is outdated/incorrect because, as you point out, we are checking _raise_on_terms_not_in_model, I'll update the comment.

I kept _raise_on_terms_not_in_model because it operates a bit further down the pipeline so it would catch the case of a generator or storage which was in the ispypsa tables but got dropped in the translator step, or similar cases. Basically, I was erring defensive and wanted to make sure no other part of the translator had silently remove expected variables from custom constraints.

def test_wildcard_relaxation_option_and_cost_apply_to_every_constraint(
csv_str_to_df, sample_model_config
):
"""A blank expansion_id relaxation option (and a blank expansion_id, blank

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 is I think a fairly self-indulgent (non-blocking) note purely based on my apparent inability to hold these connections in my head - I would find this kind of test in particular much easier to read (+edit/update/extend/etc) with a few brief comments just pointing out how the inputs connect/lead to the expected outputs.

sub-note - do you need to have the ispypsa_tables = _constraint_tables(csv_str_to_df) line here if all of those tables are written over in subsequent lines?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That's a fair comment, a couple of comments would probably go a long way. I'll add a few and maybe update the claude.md

I also added to the comments for:

  • test_constraint_with_no_lhs_terms_dropped_and_logged
  • test_relaxation_option_for_constraint_not_in_model_is_dropped
  • test_relaxation_on_greater_equal_constraint_adds_capacity_to_lhsI'v

That's right, ispypsa_tables = _constraint_tables(csv_str_to_df), isn't needed. Probably a clearer read if its removed.

nick-gorman and others added 5 commits October 22, 2026 11:13
Co-authored-by: EllieKallmier <61219730+EllieKallmier@users.noreply.github.com>
Co-authored-by: EllieKallmier <61219730+EllieKallmier@users.noreply.github.com>
…schemas

The date_from descriptions left readers to infer that a dated value has
no end date, that rows only supersede each other within their group, and
that a group without an undated row is absent from earlier periods. The
term_type description still said load terms are skipped, which no longer
matches the translator, and called storage_output discharge rather than
net dispatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The note that new-entrant renaming must be followed was stale after #144,
and only ever applied at coarser granularities, where custom constraints
are not templated. The existing-unit templated tables the TODO waited on
now exist, so name the target tables directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nick-gorman and others added 4 commits October 22, 2026 12:52
The I/O example described its returned frames in prose, against the
convention that DataFrame examples are always shown as full tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The source note claimed the translator and pypsa_build take variable names
as given, but the translator now raises on terms whose component the
configured model doesn't build. Say what each check covers instead, with
the REZ connection path case the schema rule can't see.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Several tests depend on rows that live only in the _constraint_tables
baseline, so a reader couldn't see why the expected output follows. Add
docstrings and comments tying inputs to outputs, and drop a helper call
whose tables were all overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tests whose expected output depends on combining inputs, or on rows hidden
in a shared baseline helper, were hard to follow without comments. Ask for
brief comments that name the specific rows and values, so they explain the
result rather than gesture at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nick-gorman
nick-gorman merged commit 70fb338 into main Oct 22, 2026
15 checks passed
@nick-gorman
nick-gorman deleted the translate-custom-constraints branch October 22, 2026 02:05
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