Skip to content

Read a PLEXOS expansion plan and write an extendable component for it - #9

Closed
thomaschristopherking wants to merge 21 commits into
mainfrom
issue-116-plexos-pypsa-sienna-capacity-expansion-portfolio
Closed

Read a PLEXOS expansion plan and write an extendable component for it#9
thomaschristopherking wants to merge 21 commits into
mainfrom
issue-116-plexos-pypsa-sienna-capacity-expansion-portfolio

Conversation

@thomaschristopherking

@thomaschristopherking thomaschristopherking commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

TL;DR

A PLEXOS model states what it may build in Build Cost, Max Units Built, WACC,
Economic Life, Technical Life and FO&M Charge, and the reader named none of them: the
AEMO 2024 ISP file alone carries 8,539 Generator.Build Cost rows and 372 objects with a
Max Units Built, all of which the first leg dropped. Max Units Built above zero now makes
an object a candidate, and a candidate becomes an extendable PyPSA component carrying what
building it costs.

First of four for transition-zero/tz-infra-interop#116.
Later parts carry the build and retirement years and the emissions constraints, then write
the SiennaSchemas investments portfolio. Based on issue-333-plexos-pypsa-review-findings.

Files to review (38, +1534 / -252):

File Why
docs/specs/2026-09-08-plexos-sienna-investments-design.md (start here) The agreed design for the whole stack, including three ways the destination schema differs from what the work was scoped against.
interop/plugins/shared/plexos_pypsa_translations/_expansion.py (new) The one expansion rule the Generator, Battery and pumped-storage turbine share.
interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py RatedCapacity: what an object has, and what one unit of it is.
interop/plugins/shared/pypsa_sienna_translations/_shared.py Which capacity the second leg reads for an extendable component.
interop/plugins/sinks/emit_pypsa_network.py The new columns are optional, which is what keeps a fixed-fleet network byte-identical.
tests/features/plexos_to_pypsa/generators.feature Candidate, plant that also expands, three ways a build goes unpriced, fixed generator.

Important

A PLEXOS Battery states Max Power per unit, and the mapping read it as the whole
object's power. A Battery stating Units 3 therefore reached PyPSA at a third of its
power. This corrects it, so an existing dispatch translation of a multi-unit battery
changes
. record_battery_outages already derated p_max_pu by units_out / units,
which only means anything if p_nom covers every unit.

Why

Every translation this repository makes gives an operations model, and the PLEXOS leg fixed
every capacity on purpose in five places. The note beside each read "v1 translates a dispatch
model, so capacity is fixed". A published expansion plan therefore reached PyPSA as a fleet
that cannot change, and the cost of building anything never left the XML.

How

One reading decides it: Max Units Built above zero. _expansion.py holds that rule once,
so a Generator, a Battery and a pumped-storage turbine cannot drift apart. Each class hands
it a RatedCapacity — the capacity the object already has, and the size of one unit — rather
than leaving the second to be inferred from the first, which is what let a turbine's
p_nom_max come out at twice the right number in the first draft.

PyPSA 1.2 takes overnight_cost in preference to capital_cost and works the annuity out
itself from discount_rate and lifetime. So this writes those three plus fom_cost and
assembles no capital cost of its own — an annuity here would be a second implementation of
one PyPSA already ships, and the two would disagree the first time either changed.

Economic Life is the capital recovery period, which is the period PyPSA annuitises across,
so it takes lifetime. Technical Life and the size of one unit have no PyPSA field, so
they travel in the extensions sidecar as technical_life_years and unit_size_mw.

Reviewer notes

  • A candidate whose build nothing prices is left out. PyPSA raises where overnight_cost
    is set and discount_rate is not, so a candidate needs all three of Build Cost, WACC
    and Economic Life or the network cannot be solved at all. Each drop is a
    COMPONENT_SKIPPED event and one warning names a few.
  • A plant that already runs is never dropped for an unpriced build. It keeps the capacity
    it runs, fixed, and the missing price is recorded as NOT_MAPPED against the component.
    Dropping it would lose real capacity from the network.
  • A candidate that has nothing built takes the capacity it may build as its p_nom.
    PyPSA reads p_nom only for a component whose capacity is fixed, so this binds no
    dispatch. It is what every per-unit field is read against — p_min_pu, a ramp limit, an
    availability profile stated in MW — and against zero each of those would come out at zero.
  • The second leg reads p_nom_opt for an extendable component. A PLEXOS candidate must
    not reach a Sienna operations system as if it were built. A component with a p_nom_min
    above zero still reaches it: that is capacity a build cannot take away.
  • WACC reads as a percentage only when the model says so. as_rate divides by 100
    where the stated unit is % and takes the number as written otherwise.

Tests

Sixteen scenarios across generators.feature, storage_units.feature,
dated_properties.feature and the pypsa_to_sienna features: a candidate generator, battery
and turbine with every field checked; a plant that already runs and may also expand; three
ways a build goes unpriced; a plant kept despite an unpriced build; a candidate's dated
capacity; a solve that builds none of one candidate and 400 MW of another. Full suite (600),
ruff, mypy, the import contracts and both plugin lints are green.

CI does not run on this pull request: the workflows trigger on a pull request into main,
and this one targets the branch below it in the stack.

Follow-up

  • The build year and the retirement year, which need the whole dated Units series the
    horizon_year narrowing throws away.
  • ExtensionKind.CONSTRAINT, so an emissions target survives the hop through PyPSA.
  • The pypsa-to-sienna-investments leg and the portfolio sink.

Open workspace in Conductor

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review by /review-fix-cycle.

Labels follow Conventional Comments. (blocking) means the reviewer would not merge without it settled. (non-blocking) means it can land after.

Comment thread interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_generators.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_storage_units.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/decisions.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_generators.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_generator_decisions.py Outdated

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review by /review-fix-cycle.

Labels follow Conventional Comments. (blocking) means the reviewer would not merge without it settled. (non-blocking) means it can land after.

Comment thread interop/plugins/shared/pypsa_sienna_translations/_generators.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_generator_derivation.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_generators.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_batteries.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_expansion.py
Comment thread interop/plugins/shared/plexos_pypsa_translations/_generator_derivation.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_expansion.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_expansion.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_expansion.py Outdated

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review by /review-fix-cycle.

Labels follow Conventional Comments. (blocking) means the reviewer would not merge without it settled. (non-blocking) means it can land after.

Comments that do not anchor to the diff

docs/translation_mappings/translation-from-plexos-to-pypsa.md:376

[r3-13] issue (blocking): This paragraph says a Battery or a turbine with Units 0 is not written, which the branch's own candidate rule no longer does.

Now:

 374 | SoC` are `dropped`, and the full energy capacity is available.
 375 | 
 376 | A `Battery` or a turbine can have a rated power of zero. For example, `Units 0` puts a unit
 377 | into storage. Such a unit cannot dispatch. The translator does not write it, and it makes a
 378 | `COMPONENT_SKIPPED` event that gives the name of the unit.
 379 | 
 380 | ## Pumped storage → `StorageUnit`

Fix: Qualify the paragraph: a rated power of zero skips the object only where it states no Max Units Built; a candidate with Units 0 takes the capacity it may build as its p_nom and is written. Net: 0 lines.

Comment thread interop/plugins/shared/plexos_pypsa_translations/_batteries.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_generators.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_generators.py
Comment thread interop/plugins/shared/pypsa_sienna_translations/_shared.py Outdated
Comment thread interop/plugins/shared/pypsa_sienna_translations/_shared.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_generator_derivation.py Outdated

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review by /review-fix-cycle.

Labels follow Conventional Comments. (blocking) means the reviewer would not merge without it settled. (non-blocking) means it can land after.

Comment thread interop/plugins/shared/plexos_pypsa_translations/_generators.py
Comment thread interop/plugins/shared/plexos_pypsa_translations/_generators.py
Comment thread interop/plugins/steps/pypsa_to_sienna_map_components.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_expansion.py Outdated
Comment thread tests/features/plexos_to_pypsa/generators.feature Outdated
Comment thread interop/plugins/steps/pypsa_to_sienna_map_components.py Outdated

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review by /review-fix-cycle.

Labels follow Conventional Comments. (blocking) means the reviewer would not merge without it settled. (non-blocking) means it can land after.

Comment thread interop/plugins/shared/plexos_pypsa_translations/_batteries.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_expansion.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_batteries.py Outdated
Comment thread interop/plugins/shared/pypsa_sienna_translations/_storage.py Outdated

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review by /review-fix-cycle.

Labels follow Conventional Comments. (blocking) means the reviewer would not merge without it settled. (non-blocking) means it can land after.

Comment thread interop/plugins/shared/plexos_pypsa_translations/_storage_hydro.py Outdated
Comment on lines +164 to +168
def as_rate(stated: float | None, stated_unit: str | None) -> float | None:
"""A rate the model writes as a percentage, read as the fraction the destination wants."""
if stated is None:
return None
return stated / PERCENT if is_percent(stated_unit) else stated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[r6-02] suggestion (blocking): as_rate converts a WACC percentage at map time, but CANONICAL_UNIT already converts Build Cost, FO&M Charge and Economic Life at stage time.

Now:

 162 | 
 163 | 
 164 | def as_rate(stated: float | None, stated_unit: str | None) -> float | None:
 165 |     """A rate the model writes as a percentage, read as the fraction the destination wants."""
 166 |     if stated is None:
 167 |         return None
 168 |     return stated / PERCENT if is_percent(stated_unit) else stated
 169 | 
 170 | 

Fix: The code now:

# _shared.py
def as_rate(stated: float | None, stated_unit: str | None) -> float | None:
    if stated is None:
        return None
    return stated / PERCENT if is_percent(stated_unit) else stated

# _expansion.py
def _discount_rate(source: CandidateSource) -> Decision:
    wacc = source.props.get(PlexosProperty.WACC)
    rate = as_rate(wacc, source.stated_units.get(PlexosProperty.WACC))

The code to use:

# plexos_units.py, beside the other four expansion properties
(PlexosCollection.GENERATORS, PlexosProperty.WACC): UNIT_FRACTION,
(PlexosCollection.BATTERIES, PlexosProperty.WACC): UNIT_FRACTION,
...
UNIT_FRACTION: {UNIT_FRACTION: 1.0, UNIT_PERCENT: 1.0 / _HUNDRED},

# _expansion.py
discount_rate=_from_property(source, PlexosProperty.WACC, None, DIRECT_DERIVATION),

This deletes as_rate, the stated_units field on CandidateSource, StagedObject and SourceGenerator, and the gen_units, battery_units and generator_units lookups.

Comment thread interop/plugins/shared/pypsa_sienna_translations/_shared.py Outdated
Comment thread interop/plugins/shared/plexos_pypsa_translations/_expansion.py Outdated
Comment thread docs/translation_mappings/translation-from-plexos-to-pypsa.md Outdated

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Review by /review-fix-cycle.

Labels follow Conventional Comments. (blocking) means the reviewer would not merge without it settled. (non-blocking) means it can land after.

Comment thread interop/plugins/shared/pypsa_sienna_translations/_shared.py Outdated
Base automatically changed from issue-333-plexos-pypsa-review-findings to main September 9, 2026 09:21
@thomaschristopherking
thomaschristopherking added this pull request to stack #13 September 9, 2026 09:58
@thomaschristopherking thomaschristopherking added review-fix-running An automated review-and-fix cycle is running on this branch and removed review-fix-running An automated review-and-fix cycle is running on this branch labels Sep 9, 2026
thomaschristopherking and others added 11 commits September 9, 2026 12:29
Writes the agreed design before the work that implements it: what makes a
PLEXOS object a candidate, which half of the cost PyPSA works out for itself,
why one candidate becomes one technology, and why the portfolio is written
beside the base system it expands.

Three things in the SiennaSchemas investments namespace differ from what the
work was scoped against, and each one decides part of the design, so the spec
states them: there is no Node or Zone, the existing-fleet records are
supplemental attributes rather than components, and TechnologyFinancialData
has no field for a weighted average cost of capital.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas C. King <thomas.k@transitionzero.org>
The PLEXOS reader named Units and Units Out and nothing else about building,
and every capacity mapping wrote p_nom_extendable False on purpose. So a model
stating what it may build lost all of it on the first leg.

PlexosProperty now names Build Cost, Max Units Built, WACC, Economic Life,
Technical Life and FO&M Charge, and Build Cost and FO&M Charge convert from
$/kW as they stage. Max Units Built above zero is what makes an object a
candidate, and the rule is the same for a Generator, a Battery and a
pumped-storage turbine, so one derivation serves all three.

PyPSA takes overnight_cost in preference to capital_cost and works the annuity
out from the discount rate and the lifetime, so this writes those four fields
and assembles no cost of its own. Economic Life is the recovery period PyPSA
annuitises across; Technical Life and the size of one unit have no PyPSA field
and travel in the extensions sidecar instead.

A candidate that has nothing yet takes the capacity it may build as its p_nom.
PyPSA reads p_nom only for a component whose capacity is fixed, and it is what
every per-unit field on the component is read against, so against nothing each
of those would come out at zero.

A candidate that states no Build Cost is left out: nothing prices building it,
so an expansion would take it for free. Each one is recorded, and one warning
names a few of them.

Every new column is null for an object the model cannot build, and the sink
omits a null column, so the dispatch pipelines write what they wrote before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas C. King <thomas.k@transitionzero.org>
The generator path and the storage path each wrote the expansion mapping, and
the storage copy inferred the size of one unit from the rated power. That
inference was wrong for two of the three classes it served. A pumped-storage
turbine's rated power is Max Capacity times Units, so a turbine with two 100 MW
units and one more to build got a ceiling of 600 MW where the model offers 300;
a battery's rated power is its Max Power alone, so the same expression read one
value as two different things and let a solver remove capacity the model states
as built. The turbine also never reached the rule at all when it stated Units =
0, because its rated power worked out to zero and the object was dropped as
unable to dispatch, which is exactly the candidate the documentation says the
translator carries.

The rated power now arrives as a pair: what the object already has, and what
one unit of it is. Each class states both for itself, so a Generator and a
turbine read Max Capacity as one unit and multiply by Units, and a Battery
reads Max Power. One module, _expansion.py, holds the rule the three share: the
extendable flag, the floor, the ceiling, the build cost, the discount rate, the
lifetime, the fixed cost, and the two values that travel in the sidecar. The
floor is the rated power the object has, and the ceiling adds Max Units Built
units of one unit's rated power to it. A candidate with nothing built takes the
capacity it may build as its p_nom, on the storage path as it already did on
the generator path. A mapping dataclass holds one ExpansionDecisions field and
adopts its column declarations, so the reported events and the destination row
still come from one walk.

A candidate that states a Build Cost but no WACC is now left out with the
Build Cost case. PyPSA's cost consistency check raises whenever an
overnight_cost is set and the discount rate is missing, whatever the strict
setting, and Network.optimize runs that check, so such a network cannot be
solved at all.

On the PyPSA to Sienna leg, base_power and the active power limits took
p_nom_opt from any extendable component. An unsolved network states zero there,
so every PLEXOS candidate this branch now writes would have translated at zero
capacity. The generator, renewable, storage and hydro mappings now guard that
choice with p_nom_opt above zero, as the link mapping already did and as the
mapping document already described.

Two generator scenarios asserted derivation text that named a generator; the
shared rule names an object, and they now assert that. Two scenarios stating a
Build Cost and no WACC gained one, since the translator no longer writes a
network PyPSA refuses. New scenarios cover a pumped-storage turbine that runs
two units and may build a third, and one that has yet to build anything.

Addresses r1-01, r1-02, r1-03, r1-07, r1-08, r1-09, r1-10, r1-11, r1-15, r1-16,
r1-17, r1-18, r1-19, r1-20, r1-21, r1-22, r1-23, r1-24, r1-25, r1-26, r1-27,
r1-29, r1-30 and r1-31.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas C. King <thomas.k@transitionzero.org>
code-review/ is committed with the code it judges, so a teammate and a fresh
clone start with the rules this repository has earned rather than with none.
The README says what each file in it is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas C. King <thomas.k@transitionzero.org>
…ne owner

The effective_p_nom rule stood in seven places across the PyPSA leg, and each
copy read a p_nom_opt of zero as an unsolved network. A solve that builds none
of an extendable component writes p_nom_opt 0; only a network no solve has
touched leaves the column out. Reading that 0 as unsolved carried the whole
candidate capacity into the Sienna operations system, adding a plant the
expansion refused. The rule now lives once in the pypsa_sienna_translations
_shared module, keyed on whether the network states a p_nom_opt at all, and the
generator, renewable, hydro, storage and link modules call it. The four
fill_defaults calls leave p_nom_opt null rather than filling it to zero, so an
unsolved network is still distinguishable.

A candidate battery divided its stated Capacity by the capacity it may build, so
its max_hours came out Max Units Built times too small. A Battery states its
Capacity beside one unit's Max Power, so both now per-unitise against that one
power, and the initial state of charge reads its percentage against p_nom times
max_hours rather than against one unit's energy.

A candidate that states no Economic Life is left out beside one that states no
Build Cost or no WACC. PyPSA's lifetime default is infinity, not NaN, so nothing
refuses the network and the build is annuitised as a perpetuity; a candidate
stating WACC 0 with no Economic Life would be built for free.

The rest is duplication and dead code. SourceGenerator no longer restates the
candidate rule in plain floats: it reads its p_nom off the Decision _expansion
builds, so the reported event and the emitted row cannot disagree. The warning
about an unpriced candidate moves beside that rule, so the generator path and
the storage path both cap it at a few names instead of the storage path warning
once per object. NOT_A_CANDIDATE becomes NOTHING_TO_REPORT, which is what a
genuine candidate stating no FO&M Charge also gets. ExpansionDecisions.is_candidate
and RatedObject.stated_units had no reader, and three docstrings restated their
own signature or a rule the code does not have.

The scenarios that reached the expansion path with a Build Cost and a WACC alone
now state an Economic Life, and one more candidate stands beside them to show
what leaving it out does. Two new assertions pin the numbers the fixes change: a
candidate battery's max_hours and initial level, and the base_power of an
extendable generator a solve built none of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas C. King <thomas.k@transitionzero.org>
…ispatch system

PLEXOS states a Battery's Max Power per unit, as the mapping document's own account of
what a candidate is already said, so a Battery running two units now reaches PyPSA at
twice that power rather than once. The units-out trace derates p_max_pu by
units_out / units, so p_nom has to stand for every unit before that derate means
anything. A Battery stating Units 0 and no Max Units Built now works out to no rated
power and is left out, with the reason recorded and warned about, as a turbine with no
units already was. (r3-01, r3-08)

The pypsa-to-sienna leg leaves out an extendable component no solve has sized. PyPSA
ignores the p_nom of an extendable component, and the network the PLEXOS leg writes puts
the capacity a candidate may build there, so reading it back gave a Sienna operations
system a plant nobody has built. A component a solve has sized still translates at its
p_nom_opt. (r3-06)

One carrier now reports every PLEXOS skip. SkippedComponent moves to decisions.py and
carries the group a warning speaks for, so the unpriced-candidate rule needs neither a
return type of its own nor a field of its own, and every generator skip warns as a
storage skip already did. (r3-02)

Deletes SourceGenerator.existing, which nothing read (r3-11), and two docstrings that
restated the line below them (r3-09, r3-10). The mapping documents now say which reading
of Max Power is correct, that a turbine's p_nom is Max Capacity times Units and nothing
else, that a candidate stating Units 0 is written rather than skipped, and that a
candidate does not reach a Sienna operations system. (r3-13, r3-14)

The scenarios follow the behaviour. The battery p_nom event cites Units beside
Max Power. The three scenarios asserting that p_nom_extendable round-trips into
extensions.json give their component a p_nom_opt, since a network no solve has touched
no longer translates its candidates. The infeasible-dispatch warning is now the note the
skip records, which says "sits above" where the warning it replaces said "is above".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas C. King <thomas.k@transitionzero.org>
A dated Max Capacity becomes a p_max_pu series, scaled so the band in force reads
as a share of p_nom. The scale was the unit count the object runs, which a
candidate states as zero, so every snapshot of the band held the candidate at
zero and a solve could build none of it. The unit count p_nom stands for is what
the object runs, or what a candidate running none of them may build, which is the
reading derive_p_nom already takes, so CandidateSource states it once and the
dated rows scale by it. A scenario covers a candidate whose capacity rises during
the year: it fails against the old scale and passes against the new one.
Addresses r4-01 and r4-04.

_drop_unbuilt_candidates had one caller and two working lines, so they move into
_prepare_source beside the SkipRule it already applies (r4-09). The reason the
deleted docstring gave stands in unbuilt_candidate_skip, so no comment replaces
it (r4-06). Drops the paragraph defending the grouped warning in find_unpriced_build
(r4-07), and the two feature comments repeating the decisions.md text the same
scenario asserts (r4-08).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas C. King <thomas.k@transitionzero.org>
A Generator, a Battery or a turbine that states a Max Units Built the model
prices nothing for was left out whole, so a plant already generating vanished
from the network because no expansion of it was priced. An object with units in
service now keeps the capacity it runs, with that capacity fixed, and only the
build is left out: decisions.md carries a NOT_MAPPED row naming the property the
model left out, and one warning names a few of the objects it happened to. An
object with no units yet is the build and nothing else, so it is still skipped.
(r5-04)

The battery's state_of_charge_initial named Capacity and Initial SoC while its
value came from the energy of every unit. The energy the battery holds is now a
Decision of its own, so p_nom and max_hours travel with the level and the
derivation states what the code does. (r5-01, r5-09)

Name a case the zero guard in the Sienna storage initial level can still meet,
since an extendable unit no solve has sized is now dropped before it. (r5-10)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas C. King <thomas.k@transitionzero.org>
RatedPower carries the PLEXOS class and the capacity property, which is
everything the two rating functions differed by, so the rating becomes a
method on it and the callback field goes. The derivation string still reads
"Max Power * Units" and "Max Capacity * Units", built from the property the
constant names (r6-01).

Drops two docstrings that repeat what the name beside them already says
(r6-08, r6-09), and counts the skipped generator cases the mappings table
actually lists (r6-10).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas C. King <thomas.k@transitionzero.org>
The pypsa-to-sienna scope rule read p_nom_extendable and p_nom_opt and nothing
else, so it left out every extendable component no solve had touched. A plant
that already generates and merely states what it may build states that running
capacity as p_nom_min, and PyPSA ignores the p_nom of an extendable component,
so the rule deleted real capacity from the Sienna system. The PLEXOS leg writes
exactly that shape for a plant with a Max Units Built.

A component belongs in the operations system when it holds capacity an
operations model may dispatch: any fixed component, an extendable one a solve
has sized, and now an extendable one whose p_nom_min is a capacity a build
cannot take away. effective_p_nom rates it at that floor, and the events name
p_nom_min as the attribute the rating came from. Only an extendable component
with neither a p_nom_opt nor a p_nom_min is a build the plan has yet to decide,
and that one is still left out and named in decisions.md. (r7-01)

Links read the same rating, so an extendable link with a floor now rates at it
rather than at a p_nom PyPSA never reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas C. King <thomas.k@transitionzero.org>
The ledger is committed with the code it judges, so a rule earned here is a
rule a teammate and a fresh clone start with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas C. King <thomas.k@transitionzero.org>
@thomaschristopherking
thomaschristopherking force-pushed the issue-116-plexos-pypsa-sienna-capacity-expansion-portfolio branch from ec9ed2f to 773fa30 Compare September 9, 2026 12:35
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

maintainability index

Gate: ✅ PASS
Score: A (85.08) mean over 244 files (function-weighted: 70% mean-of-functions, 30% file)
Lowest file: interop/adapters/inbound/interactive_cli/app.py at A (54.14)
Most strained function: interop/adapters/inbound/interactive_cli/app.py::_run_solve (139 lines, CC 22) at MI 33.5

All files grade A.

Full report
File Combined File MI Func mean Worst function
interop/adapters/inbound/interactive_cli/app.py 54.14 9.79 73.16 _run_solve (139ln, CC22)
interop/plugins/shared/pypsa_constants.py 55.42 55.42 55.42
interop/plugins/steps/sienna_to_pypsa/map_generators.py 57.45 29.65 69.37 _derive_thermal (66ln, CC10)
interop/plugins/validators/pypsa/bus_reference_integrity.py 60.63 68.17 57.39 validate (26ln, CC7)
interop/plugins/shared/plexos_pypsa_translations/_storage_hydro.py 63.24 46.36 70.48 _derive_turbine (28ln, CC3)
interop/plugins/steps/sienna_to_powersimulations/map_components.py 63.94 28.60 79.08 _map_bus_backed_component (134ln, CC15)
interop/plugins/steps/sienna_to_pypsa/map_transmission.py 64.18 38.69 75.10 _map_lines (52ln, CC5)
interop/plugins/shared/plexos_pypsa_translations/_generator_decisions.py 65.25 33.83 78.72 record_generator (28ln, CC8)
libs/interop-testing/src/interop_testing/steps/power_simulations.py 65.81 42.98 75.60 assert_h5_has_ts_association (37ln, CC6)
interop/plugins/sinks/emit_sienna_system_json.py 66.23 39.87 77.54 _build_bus_components (18ln, CC6)
interop/plugins/sources/stage_sienna_results.py 66.96 54.94 72.10 load_into_state (24ln, CC4)
interop/plugins/shared/plexos_pypsa_translations/_generator_derivation.py 67.17 31.00 82.67 _fuel_use (16ln, CC3)
interop/plugins/validators/pypsa/unique_names.py 67.29 82.81 60.63 validate (25ln, CC4)
interop/plugins/steps/pypsa_to_results_map_observations.py 67.34 43.27 77.65 _map_dispatch (46ln, CC5)
interop/di/discovery.py 67.95 35.46 81.88 _discover_off_the_shelf_plugins (34ln, CC3)
interop/plugins/sources/plexos_horizon.py 68.16 46.84 77.30 _model_horizon_id (14ln, CC8)
interop/plugins/steps/sienna_to_results/map_results.py 68.50 47.39 77.54 _map_storage_dispatch (36ln, CC4)
interop/plugins/sources/stage_plexos_xml.py 69.42 27.56 87.36 _property_row (27ln, CC4)
interop/plugins/validators/pypsa/generators.py 69.84 67.85 70.69 validate (13ln, CC2)
interop/adapters/outbound/markdown_report.py 70.28 40.84 82.89 _render_field (12ln, CC6)
interop/plugins/shared/plexos_pypsa_translations/_transmission.py 70.42 38.23 84.21 _read_transmission (24ln, CC6)
interop/plugins/validators/pypsa/storage_units.py 70.52 70.12 70.69 validate (13ln, CC2)
interop/plugins/sinks/emit_results_parquet.py 70.89 52.47 78.79 write (27ln, CC3)
libs/interop-testing/src/interop_testing/builders/pypsa_networks.py 70.89 33.58 86.89 add_generator (59ln, CC12)
interop/plugins/steps/sienna_to_pypsa/map_storage_units.py 71.09 41.90 83.60 _derive_phs (37ln, CC1)
interop/adapters/outbound/csv_report.py 71.24 44.99 82.50 _rows_for_event (15ln, CC7)
interop/adapters/inbound/interactive_cli/schema_prompts.py 71.28 51.67 79.68 _annotation_includes_path (9ln, CC6)
interop/plugins/validators/pypsa/lines.py 71.28 72.66 70.69 validate (13ln, CC2)
interop/plugins/shared/plexos_pypsa_translations/_loads.py 71.35 41.35 84.20 _derive_loads (21ln, CC4)
interop/plugins/shared/plexos_pypsa_translations/_batteries.py 71.43 54.55 78.66 _battery_initial_level (15ln, CC3)
interop/lints/plugin_filesystem.py 71.46 50.97 80.24 _function_violations (23ln, CC13)
interop/plugins/sources/plexos_csv_layouts.py 71.64 45.32 82.92 warn_unstageable_layout (10ln, CC3)
interop/adapters/inbound/interactive_cli/history.py 72.06 49.18 81.86 record (11ln, CC3)
interop/plugins/validators/pypsa/links.py 72.11 75.42 70.69 validate (13ln, CC2)
interop/plugins/steps/pypsa_to_sienna_map_components.py 72.11 39.85 85.94 _map_links (36ln, CC3)
interop/plugins/shared/pypsa_sienna_user_mappings.py 72.17 56.58 78.85 get_prime_mover_map (10ln, CC5)
interop/plugins/steps/pypsa_to_sienna_relate_components.py 72.63 61.38 77.45 _build_arcs (21ln, CC5)
interop/plugins/sources/stage_pypsa_network_file.py 72.79 51.02 82.12 _stage_time_series (25ln, CC5)
interop/plugins/shared/plexos_pypsa_translations/_generators.py 72.93 42.85 85.82 _map_one (28ln, CC8)
libs/interop-testing/src/interop_testing/builders/plexos_tables.py 73.25 38.13 88.30 _add_property (44ln, CC7)
interop/templates/examples/pypsa/plugins/steps/normalise_carrier.py 73.38 66.49 76.33 run (42ln, CC7)
interop/plugins/steps/sienna_to_pypsa/map_loads.py 73.73 62.73 78.44 _record_load_time_series (22ln, CC4)
interop/plugins/shared/plexos_pypsa_translations/_expansion.py 74.06 44.72 86.63 derive_expansion (35ln, CC3)
libs/interop-testing/src/interop_testing/builders/sienna_systems.py 74.19 37.36 89.98 add_line (44ln, CC2)
interop/plugins/shared/plexos_pypsa_translations/_constraints.py 74.22 56.26 81.92 _read_one (19ln, CC3)
interop/plugins/sinks/emit_pypsa_network.py 74.23 49.15 84.98 _add_buses (20ln, CC3)
interop/plugins/shared/plexos_pypsa_translations/_load_shedding.py 74.68 55.83 82.76 add_load_shedding_generators (20ln, CC7)
interop/plugins/sources/plexos_dated_properties.py 74.74 52.51 84.27 _stepped_frame (16ln, CC4)
interop/plugins/steps/sienna_to_pypsa/relate_components.py 74.83 70.56 76.66 run (36ln, CC7)
interop/core/use_cases/compare.py 74.83 49.48 85.69 _coverage (21ln, CC2)
libs/interop-testing/src/interop_testing/steps/sienna_system.py 75.01 34.13 92.54 assert_h5_ts_values (17ln, CC6)
interop/plugins/shared/plexos_units.py 75.02 49.41 86.00 stated_units (12ln, CC6)
interop/lints/plugin_inheritance.py 75.18 55.35 83.67 _check_file (30ln, CC9)
interop/plugins/sinks/emit_power_simulations_h5_sidecar.py 75.21 54.42 84.13 write_h5 (70ln, CC7)
interop/plugins/sources/stage_sienna_system_json.py 75.24 52.18 85.12 load_into_state (22ln, CC4)
interop/adapters/outbound/julia_solver.py 75.25 45.89 87.83 _run_pipeline_inner (159ln, CC2)
interop/plugins/shared/plexos_sienna_user_mappings.py 75.39 67.26 78.88 _reject_unknown_storage_kinds (12ln, CC6)
interop/plugins/shared/pypsa_sienna_translations/_lines.py 75.40 45.84 88.07 fill_line_defaults (38ln, CC6)
libs/interop-testing/src/interop_testing/builders/plexos_csv_files.py 75.42 65.12 79.84 write_csv (10ln, CC5)
interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py 75.56 43.74 89.19 rate_object (22ln, CC6)
interop/plugins/shared/pypsa_sienna_translations/_ts_info.py 75.68 74.11 76.35 collect_ts_info (40ln, CC3)
interop/plugins/shared/plexos_pypsa_translations/_shared.py 75.93 51.40 86.44 _membership_pairs (16ln, CC2)
interop/core/cli_overrides.py 76.14 57.10 84.30 __init__ (8ln, CC2)
interop/plugins/sources/stage_pypsa_network_ensemble.py 76.20 52.75 86.25 load_into_state (21ln, CC3)
interop/adapters/outbound/markdown_comparison_report.py 76.24 53.78 85.87 render (48ln, CC6)
interop/core/composition/document.py 76.33 47.16 88.83 validate_frameworks (9ln, CC3)
interop/core/adapters_config.py 76.61 58.20 84.50 load_adapters_config (14ln, CC6)
interop/plugins/shared/pypsa_destination.py 76.76 95.81 68.60 append_destination_rows (12ln, CC3)
interop/di/factories.py 77.21 42.31 92.17 make_adapter_factory (25ln, CC2)
interop/templates/examples/pypsa/plugins/sinks/emit_sienna_csv.py 77.34 74.49 78.56 write (11ln, CC5)
interop/plugins/shared/plexos_pypsa_translations/_reserves.py 77.58 50.89 89.02 _load_profiles (17ln, CC2)
interop/plugins/steps/sienna_to_powersimulations/_cost_events.py 77.89 57.47 86.65 _record_cost_curve_events (106ln, CC4)
interop/plugins/shared/plexos_pypsa_translations/_buses.py 78.13 59.75 86.00 map_buses (14ln, CC5)
libs/interop-testing/src/interop_testing/steps/pypsa_network/build_network.py 78.28 39.07 95.08 given_network_contains_generator (42ln, CC15)
interop/plugins/shared/translation_runner.py 78.38 56.08 87.94 _build_source_field (12ln, CC2)
interop/plugins/sinks/emit_power_simulations_system_json.py 78.47 63.71 84.79 _build_all_components (18ln, CC5)
interop/plugins/shared/pypsa_sienna_translations/_component_mapping.py 78.54 78.54 78.54
interop/core/use_cases/translate.py 78.80 50.23 91.04 _run_leg (14ln, CC1)
interop/plugins/shared/plexos_pypsa_translations/_storage_units.py 78.95 53.88 89.69 _dropped_values (21ln, CC3)
interop/core/composition/addressing.py 79.07 58.57 87.85 find_node (14ln, CC6)
interop/core/user_mappings_loader.py 79.09 52.67 90.41 _load (17ln, CC3)
interop/core/composition/planning.py 79.21 51.25 91.20 _reject_step_overrides_on_a_chain (9ln, CC3)
interop/plugins/validators/pypsa/loads.py 79.48 73.81 81.91 _check_static (13ln, CC2)
interop/plugins/sinks/emit_json.py 79.70 61.76 87.39 write (11ln, CC3)
interop/plugins/steps/plexos_sienna_mappings_to_carriers.py 79.75 60.11 88.17 _derive_from (13ln, CC3)
interop/core/use_cases/solve.py 79.85 57.87 89.27 _solve_sienna (28ln, CC3)
libs/interop-testing/src/interop_testing/builders/plexos_resources.py 79.93 41.88 96.24 _add_storage (12ln, CC3)
interop/adapters/outbound/highs_network_solver.py 80.08 62.62 87.56 _zero_look_ahead_outputs (9ln, CC3)
interop/plugins/sinks/emit_power_simulations_files.py 80.12 57.66 89.75 write (20ln, CC2)
libs/interop-testing/src/interop_testing/steps/pypsa_network/_written_network.py 80.21 59.63 89.03 assert_series (15ln, CC5)
interop/plugins/shared/pypsa_sienna_translations/_hydro.py 80.24 59.59 89.09 _hydro_skips (10ln, CC2)
interop/adapters/inbound/headless_cli/app.py 80.26 59.54 89.15 run_headless (54ln, CC10)
interop/plugins/shared/plexos_pypsa_translations/_generator_lookups.py 80.27 63.74 87.36 _aggregate_series (13ln, CC4)
libs/interop-testing/src/interop_testing/projects.py 80.46 75.31 82.67 write_project_plugin_in_subdir (5ln, CC1)
interop/plugins/shared/warning_text.py 80.52 97.41 73.29 name_a_few (7ln, CC2)
libs/interop-testing/src/interop_testing/steps/reports.py 80.66 66.14 86.89 assert_manifest_fields (5ln, CC4)
interop/plugins/shared/pypsa_sienna_translations/_loads.py 80.66 56.44 91.04 _load_max_active_power (44ln, CC6)
interop/core/use_cases/init_project.py 80.84 68.96 85.93 __call__ (11ln, CC3)
interop/plugins/sinks/emit_sienna_files.py 80.95 54.53 92.27 write (9ln, CC2)
interop/plugins/shared/pypsa_sienna_translations/_ensemble.py 81.16 83.00 80.38 _report_partial (20ln, CC3)
libs/interop-testing/src/interop_testing/builders/plexos_models.py 81.38 50.10 94.78 save (12ln, CC5)
libs/interop-testing/src/interop_testing/steps/files.py 81.42 61.25 90.07 assert_json_array_length (10ln, CC4)
interop/plugins/sinks/emit_sienna_files_ensemble.py 81.49 72.11 85.52 replication_paths (8ln, CC1)
interop/plugins/shared/pypsa_sienna_translations/_links.py 81.54 53.45 93.58 link_in_scope (8ln, CC1)
interop/plugins/steps/caiso_plexos_to_results.py 81.54 51.18 94.56 run (10ln, CC3)
interop/templates/examples/pypsa/plugins/sources/stage_pypsa_csv.py 81.60 69.87 86.63 load_into_state (30ln, CC6)
interop/plugins/sinks/emit_pypsa_network_ensemble.py 81.69 68.98 87.13 write (12ln, CC3)
interop/plugins/shared/pypsa_time_series.py 81.72 57.60 92.05 series_timing (16ln, CC2)
interop/core/use_cases/solve_network.py 81.82 60.31 91.04 _last_day_of_window (10ln, CC5)
interop/plugins/shared/plexos_constants.py 81.89 75.31 84.72 is_plexos_true (3ln, CC1)
interop/plugins/shared/pypsa_sienna_translations/_storage.py 81.98 59.28 91.71 fill_storage_defaults (18ln, CC1)
interop/plugins/shared/sienna_constants.py 82.16 40.53 100.00 time_series_uuid (4ln, CC1)
interop/plugins/shared/pypsa_sienna_translations/_generators.py 82.20 48.47 96.66 build_generator_ts_association (41ln, CC4)
interop/plugins/shared/power_simulations_schema.py 82.75 42.49 100.00 get_new_uuid (2ln, CC1)
interop/plugins/sources/plexos_tables.py 82.77 89.69 79.80 class_id_of (6ln, CC3)
interop/plugins/sinks/emit_carrier_mappings.py 82.82 73.76 86.71 write (10ln, CC3)
interop/core/runner.py 82.93 57.99 93.62 _build_params (12ln, CC4)
interop/plugins/steps/sienna_to_powersimulations/fill_availability.py 82.94 63.82 91.13 run (12ln, CC4)
libs/interop-testing/src/interop_testing/steps/plexos_model.py 82.97 49.44 97.34 given_model_contains_late_fraction_period_column_data_file (13ln, CC1)
interop/logging_setup.py 83.46 85.61 82.54 configure_logging (14ln, CC5)
interop/ports/inbound/translate.py 83.65 72.41 88.47 _describe_files (9ln, CC4)
interop/plugins/sinks/emit_sienna_h5_sidecar.py 84.09 66.06 91.82 _association_rows (5ln, CC3)
interop/core/extensions.py 84.10 51.44 98.09 report_unconsumed (6ln, CC4)
interop/plugins/shared/pypsa_sienna_translations/_renewables.py 84.14 61.90 93.67 _renewable_cost_loss (25ln, CC2)
interop/plugins/shared/plexos_pypsa_translations/_ensemble.py 84.35 81.18 85.71 choose_ensemble_samples (25ln, CC4)
interop/di/container.py 84.65 53.11 98.17 reporter_builder (27ln, CC1)
interop/plugins/shared/staged_samples.py 84.81 77.03 88.15 filter_to_sample (8ln, CC3)
interop/plugins/sources/stage_caiso_plexos_stack_model.py 85.12 74.39 89.72 load_into_state (15ln, CC2)
interop/plugins/shared/pypsa_sienna_translations/_shared.py 85.14 61.16 95.42 ts_association_row (33ln, CC2)
interop/plugins/shared/plexos_pypsa_translations/decisions.py 85.17 58.30 96.69 record (15ln, CC5)
interop/plugins/shared/plexos_pypsa_translations/constants.py 85.21 85.21 85.21
libs/interop-testing/src/interop_testing/builders/sienna_documents.py 85.70 59.40 96.97 find_sienna_component (10ln, CC6)
interop/plugins/shared/sienna_pypsa_translations/mapping.py 85.90 83.97 86.72 bus_id_to_v_nom (10ln, CC3)
interop/adapters/outbound/markdown_validation_report.py 85.95 70.49 92.58 render (10ln, CC3)
interop/core/use_cases/validate.py 86.20 71.01 92.70 __call__ (37ln, CC2)
libs/interop-testing/src/interop_testing/steps/pypsa_network/assert_network.py 86.33 56.37 99.17 assert_pypsa_network_empty (7ln, CC5)
interop/plugins/shared/pypsa_sienna_translations/_buses.py 86.53 55.09 100.00 fill_bus_defaults (18ln, CC1)
interop/ports/inbound/solve.py 86.60 71.34 93.14 is_success (6ln, CC2)
interop/plugins/shared/plexos_pypsa_translations/_storage_turbines.py 87.00 86.80 87.09 _states_pump_efficiency (12ln, CC2)
interop/ports/outbound/validation.py 87.04 85.79 87.58 __init__ (8ln, CC3)
interop/plugins/shared/validators.py 87.06 89.30 86.10 check_column_bounds (27ln, CC4)
libs/interop-testing/src/interop_testing/pipeline_driver.py 87.08 90.22 85.73 run_pipeline (28ln, CC5)
interop/plugins/shared/extensions_sidecar.py 87.59 77.36 91.98 _stage_extensions_sidecar (7ln, CC3)
interop/main.py 87.60 85.79 88.37 app (31ln, CC3)
interop/plugins/steps/plexos_to_pypsa/map_components.py 87.73 71.41 94.73 _models_with_a_horizon (9ln, CC2)
libs/interop-testing/src/interop_testing/builders/plexos_generator_specs.py 87.74 70.96 94.93 _property_values (6ln, CC3)
libs/interop-testing/src/interop_testing/builders/caiso_stack_models.py 87.79 67.85 96.34 _set_category (9ln, CC3)
interop/adapters/outbound/local_filesystem.py 87.93 64.75 97.86 _resolve (4ln, CC3)
interop/plugins/sinks/emit_extensions_json.py 87.94 84.09 89.59 write (7ln, CC2)
interop/core/pipeline.py 88.01 70.92 95.33 load (12ln, CC2)
libs/interop-testing/src/interop_testing/builders/sienna_results.py 88.54 68.01 97.33 save (13ln, CC4)
interop/adapters/inbound/headless_cli/overrides.py 88.57 76.75 93.64 parse_override_flags (16ln, CC6)
interop/core/use_cases/pipeline_catalog.py 89.75 72.22 97.26 _promptable_steps (5ln, CC2)
interop/plugins/steps/plexos_to_pypsa/map_loads.py 89.90 66.34 100.00 __init__ (7ln, CC2)
interop/plugins/sinks/noop.py 90.05 66.83 100.00 __init__ (2ln, CC1)
libs/interop-testing/src/interop_testing/files.py 90.10 87.03 91.42 navigate_json (26ln, CC7)
interop/plugins/shared/sienna_pypsa_translations/constants.py 90.13 86.66 91.61 pypsa_carrier (34ln, CC8)
interop/plugins/steps/plexos_to_pypsa/map_buses.py 90.24 67.47 100.00 __init__ (2ln, CC1)
interop/plugins/steps/plexos_to_pypsa/map_constraints.py 90.24 67.47 100.00 __init__ (2ln, CC1)
interop/plugins/steps/plexos_to_pypsa/map_reserves.py 90.24 67.47 100.00 __init__ (2ln, CC1)
interop/plugins/steps/plexos_to_pypsa/map_storage_units.py 90.24 67.47 100.00 __init__ (2ln, CC1)
interop/plugins/steps/plexos_to_pypsa/map_transmission.py 90.24 67.47 100.00 __init__ (2ln, CC1)
interop/ports/outbound/reporting.py 90.48 68.27 100.00 render (1ln, CC1)
libs/interop-testing/src/interop_testing/steps/pipeline.py 90.69 87.54 92.04 assert_log_does_not_contain (5ln, CC2)
interop/plugins/shared/framework_reporting.py 90.71 79.25 95.62 _derived (15ln, CC2)
interop/plugins/sources/noop.py 91.00 69.99 100.00 load_into_state (2ln, CC1)
interop/plugins/steps/sienna_to_pypsa/map_buses.py 91.42 75.42 98.27 map_buses (21ln, CC4)
interop/ports/outbound/filesystem.py 91.49 75.25 98.45 location_name (4ln, CC2)
interop/plugins/steps/noop.py 91.68 72.27 100.00 run (2ln, CC1)
interop/core/reporting.py 91.93 80.92 96.64 append (7ln, CC3)
interop/adapters/outbound/http_filesystem.py 92.33 78.45 98.29 __init__ (3ln, CC2)
interop/ports/outbound/comparison_report.py 93.42 78.08 100.00 render (1ln, CC1)
interop/plugins/sources/stage_plexos_sienna_mappings.py 93.47 78.24 100.00 _stated_row (3ln, CC1)
interop/ports/inbound/pipeline_catalog.py 93.52 78.40 100.00 by_framework_pair (3ln, CC1)
interop/plugins/shared/pypsa_sienna_translations/_arcs.py 94.18 80.61 100.00 build_arcs_source_table (27ln, CC1)
interop/plugins/shared/results_constants.py 94.33 94.33 94.33
interop/plugins/steps/sienna_to_pypsa/map_components.py 94.63 82.10 100.00 __init__ (3ln, CC1)
interop/plugins/shared/sienna_carrier_targets.py 95.54 85.12 100.00 carrier_target_discriminator (8ln, CC3)
interop/plugins/shared/pypsa_sienna_translations/_areas.py 96.13 87.11 100.00 build_areas_source_table (14ln, CC1)
interop/plugins/steps/plexos_to_pypsa/map_generators.py 98.09 93.63 100.00 __init__ (2ln, CC1)
interop/plugins/steps/plexos_to_pypsa_add_load_shedding.py 98.96 96.55 100.00 __init__ (2ln, CC1)
interop/__init__.py 100.00 100.00 100.00
interop/adapters/__init__.py 100.00 100.00 100.00
interop/adapters/inbound/__init__.py 100.00 100.00 100.00
interop/adapters/inbound/base.py 100.00 100.00 100.00 run (1ln, CC1)
interop/adapters/inbound/headless_cli/__init__.py 100.00 100.00 100.00
interop/adapters/inbound/interactive_cli/__init__.py 100.00 100.00 100.00
interop/adapters/outbound/__init__.py 100.00 100.00 100.00
interop/adapters/outbound/noop_report.py 100.00 100.00 100.00 render (2ln, CC1)
interop/adapters/outbound/write_tracking_filesystem.py 100.00 100.00 100.00 __init__ (3ln, CC1)
interop/core/__init__.py 100.00 100.00 100.00
interop/core/composition/__init__.py 100.00 100.00 100.00
interop/core/factories.py 100.00 100.00 100.00 __call__ (1ln, CC1)
interop/core/plugin_errors.py 100.00 100.00 100.00 __init__ (7ln, CC1)
interop/core/results_format.py 100.00 100.00 100.00
interop/core/use_cases/__init__.py 100.00 100.00 100.00
interop/core/user_mappings.py 100.00 100.00 100.00
interop/di/__init__.py 100.00 100.00 100.00
interop/lints/__init__.py 100.00 100.00 100.00
interop/plugins/__init__.py 100.00 100.00 100.00
interop/plugins/shared/__init__.py 100.00 100.00 100.00
interop/plugins/shared/caiso_plexos_constants.py 100.00 100.00 100.00
interop/plugins/shared/constants.py 100.00 100.00 100.00
interop/plugins/shared/plexos_pypsa_translations/__init__.py 100.00 100.00 100.00
interop/plugins/shared/pypsa_ensemble_manifest.py 100.00 100.00 100.00 dump_ensemble_manifest (2ln, CC1)
interop/plugins/shared/pypsa_sienna_translations/__init__.py 100.00 100.00 100.00
interop/plugins/shared/pypsa_sienna_translations/_prime_mover.py 100.00 100.00 100.00 enrich_prime_mover (16ln, CC3)
interop/plugins/shared/results_manifest.py 100.00 100.00 100.00 _timezone_must_be_iana (8ln, CC2)
interop/plugins/shared/sienna_pypsa_translations/__init__.py 100.00 100.00 100.00
interop/plugins/shared/sienna_pypsa_translations/reporters.py 100.00 100.00 100.00 record_dropped (4ln, CC1)
interop/plugins/shared/sienna_results_constants.py 100.00 100.00 100.00 _build_variable_csv (2ln, CC1)
interop/plugins/shared/sienna_to_powersimulations/__init__.py 100.00 100.00 100.00
interop/plugins/sinks/__init__.py 100.00 100.00 100.00
interop/plugins/sinks/_sienna_files.py 100.00 100.00 100.00 _serialise (8ln, CC1)
interop/plugins/sources/__init__.py 100.00 100.00 100.00
interop/plugins/steps/__init__.py 100.00 100.00 100.00
interop/plugins/steps/plexos_to_pypsa/__init__.py 100.00 100.00 100.00
interop/plugins/steps/sienna_to_powersimulations/__init__.py 100.00 100.00 100.00
interop/plugins/steps/sienna_to_pypsa/__init__.py 100.00 100.00 100.00
interop/plugins/steps/sienna_to_results/__init__.py 100.00 100.00 100.00
interop/plugins/validators/__init__.py 100.00 100.00 100.00
interop/plugins/validators/pypsa/__init__.py 100.00 100.00 100.00
interop/ports/__init__.py 100.00 100.00 100.00
interop/ports/errors.py 100.00 100.00 100.00 __init__ (8ln, CC1)
interop/ports/inbound/__init__.py 100.00 100.00 100.00
interop/ports/inbound/compare.py 100.00 100.00 100.00 summary (2ln, CC1)
interop/ports/inbound/init_project.py 100.00 100.00 100.00 __init__ (3ln, CC1)
interop/ports/inbound/overrides.py 100.00 100.00 100.00
interop/ports/inbound/validate.py 100.00 100.00 100.00 __call__ (10ln, CC1)
interop/ports/outbound/__init__.py 100.00 100.00 100.00
interop/ports/outbound/netcdf.py 100.00 100.00 100.00 netcdf_engine (11ln, CC3)
interop/ports/outbound/network_solver.py 100.00 100.00 100.00 solve (13ln, CC1)
interop/ports/outbound/solver.py 100.00 100.00 100.00 is_provisioned (7ln, CC1)
interop/ports/outbound/unit_commitment.py 100.00 100.00 100.00
interop/ports/outbound/validation_report.py 100.00 100.00 100.00 render (1ln, CC1)
libs/interop-testing/src/interop_testing/__init__.py 100.00 100.00 100.00
libs/interop-testing/src/interop_testing/builders/__init__.py 100.00 100.00 100.00
libs/interop-testing/src/interop_testing/builders/plexos_vocabulary.py 100.00 100.00 100.00
libs/interop-testing/src/interop_testing/steps/__init__.py 100.00 100.00 100.00
libs/interop-testing/src/interop_testing/steps/caiso_stack_model.py 100.00 100.00 100.00 given_caiso_stack_model (2ln, CC1)
libs/interop-testing/src/interop_testing/steps/isolation.py 100.00 100.00 100.00 isolated_cwd (5ln, CC1)
libs/interop-testing/src/interop_testing/steps/plexos_resources.py 100.00 100.00 100.00 given_model_contains_battery (17ln, CC1)
libs/interop-testing/src/interop_testing/steps/pypsa_network/__init__.py 100.00 100.00 100.00
libs/interop-testing/src/interop_testing/steps/sienna_results.py 100.00 100.00 100.00 _series (2ln, CC2)

@thomaschristopherking thomaschristopherking removed the review-fix-running An automated review-and-fix cycle is running on this branch label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Mutation testing report

Score: 80.5% (343 killed / 426 tested)

⚠️ The run was cut short with 146 mutants unchecked, so this score covers only the part that ran.

Status Count
🎉 Killed 343
🙁 Survived 83
🫥 No tests 1407
⏰ Timeout 71
🤔 Suspicious 0
🔇 Skipped 0
💥 Segfault 0
🧙 Caught by type check 1357
❓ Not checked 146

Timeout-prone functions

Each timeout burns its whole budget (timeout_multiplier and timeout_constant in pyproject.toml) before mutmut kills it, so the functions below dominate wall-clock cost. Adding them to do_not_mutate is the fastest way to cut CI time.

Function Timeouts
interop.adapters.outbound.highs_network_solver.xǁHighsNetworkSolverǁsolve 23
interop.adapters.outbound.highs_network_solver.x__reported_cost 7
interop.adapters.outbound.highs_network_solver.x__zero_look_ahead_outputs 6
interop.adapters.outbound.highs_network_solver.xǁHighsNetworkSolverǁsnapshot_bounds 5
interop.core.use_cases.solve_network.x__last_day_of_window 4
interop.core.use_cases.solve_network.xǁSolveNetworkUsingPortǁ_solve_one 3
interop.ports.outbound.netcdf.x_netcdf_engine 3
interop.adapters.outbound.highs_network_solver.x__snapshots_between 3
interop.adapters.outbound.highs_network_solver.x__warn_if_cut_short 3
interop.core.use_cases.solve_network.xǁSolveNetworkUsingPortǁ_resolve_bounds 2
interop.core.use_cases.solve_network.x__reject_bad_request 2
interop.core.use_cases.solve_network.x_solve_windows 2
interop.core.use_cases.solve_network.x__end_of_day 2
interop.core.use_cases.solve_network.xǁSolveNetworkUsingPortǁ__call__ 1
interop.adapters.outbound.highs_network_solver.x__snapshot_coordinate 1
interop.adapters.outbound.highs_network_solver.x__solver_options 1
interop.adapters.outbound.highs_network_solver.x__record_reported_snapshots 1
interop.adapters.outbound.highs_network_solver.x__discard_look_ahead 1
interop.adapters.outbound.highs_network_solver.x__combine 1

@thomaschristopherking thomaschristopherking added the review-fix-running An automated review-and-fix cycle is running on this branch label Sep 10, 2026
@thomaschristopherking

thomaschristopherking commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

review-fix-cycle heartbeat: 2026-09-10T15:57:41Z

… no price

Addresses r9-01, r9-02, r9-03, r9-04, r9-05, r9-06, r9-09, r9-10, r9-11,
r9-12, r9-13 and r9-16.

r9-01 and r9-02: `_find_unpriced_build` tested only that a property was
present, so a candidate that states `Build Cost` 0 became an extendable
component PyPSA prices at nothing, and one that states `Economic Life` 0 gave
PyPSA an infinite annuity factor. `UnpricedBuild.prices_a_build` now reads a
stated zero as no price. It keeps a stated `WACC` of 0, which the suggested fix
would have dropped: a zero discount rate is the rate of a model that does not
discount, and PyPSA prices such a build at overnight_cost / lifetime.
`_names_unpriced` carries the source's own value, so `decisions.md` shows the
zero the model stated.

r9-03: `_map_links` applied no candidate drop, so an extendable Link no solve
had sized reached Sienna rated at its `p_nom` while the generators beside it
were dropped.

r9-05: the candidate drop belongs to a whole source table, so it moves from
`_prepare_source`, which runs one time for each mapping, up to `_scope_rules`.
`fill_capacity_defaults` fills the four capacity columns for the group, so a
new mapping cannot reach the drop without the column it reads.

r9-04: `CapacityColumns` bundles the four column names the seven call sites
each stated in full.

r9-06: `warn_about_groups` in `decisions.py` now writes every grouped warning,
and `SkipGroup.outcome` carries the words that differ.

r9-09, r9-10, r9-11, r9-12 and r9-16: cut five docstrings back to the
constraint each one states.

r9-13: a feature step asserted `capital_cost` is 0, which is PyPSA's own
default for a column this repository never writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automatic review-and-fix, round 9

Four reviewers ran against origin/main...HEAD. Commit 53e4227 acts on the
comments below.

Acted on

  • r9-01 (correctness, major) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:296_find_unpriced_build tests only that a property is present, so a candidate stating Build Cost 0 becomes an extendable component whose expansion PyPSA prices at nothing.
  • r9-02 (correctness, major) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:219 — A candidate stating Economic Life 0 passes _PRICES_A_BUILD and writes lifetime = 0, which makes PyPSA's annuity factor infinite.
  • r9-03 (correctness, major) interop/plugins/steps/pypsa_to_sienna_map_components.py:312_map_links does not apply unbuilt_candidate_skip, so an extendable Link no solve has sized reaches Sienna rated at its p_nom while the generators beside it are dropped.
  • r9-04 (thermo, major) interop/plugins/shared/pypsa_sienna_translations/_shared.py:61 — The four capacity column parameters hold the same four strings at all seven call sites, because PyPSA names these columns the same on a Generator, a StorageUnit and a Link.
  • r9-05 (thermo, major) interop/plugins/steps/pypsa_to_sienna_map_components.py:311 — The candidate drop applies to a whole source table, but the step applies it one time for each mapping, below the _scope_rules layer that already holds the drops a table shares.
  • r9-06 (thermo, major) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:263warn_about_dropped_builds groups names, counts them and names a few, which warn_about_skips in decisions.py already does.
  • r9-09 (pruning, minor) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:3 — This paragraph argues for the shared module and narrates its calling convention, so it is reviewer justification.
  • r9-10 (pruning, minor) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:189 — This docstring repeats docs/translation_mappings/translation-from-plexos-to-pypsa.md almost word for word, so it duplicates another artifact.
  • r9-11 (pruning, minor) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:256 — This docstring lists the three statements below it, so it paraphrases the code.
  • r9-12 (pruning, minor) interop/plugins/shared/pypsa_sienna_translations/_shared.py:84 — The first three sentences repeat EFFECTIVE_P_NOM_DERIVATION and UNBUILT_CANDIDATE_NOTE, so only the last clause earns its place.
  • r9-13 (pointless, minor) tests/features/plexos_to_pypsa/generators.feature:517 — The step asserts that capital_cost is 0, which is PyPSA's own default for a column no part of interop ever writes.
  • r9-16 (pruning, nit) interop/plugins/shared/plexos_pypsa_translations/decisions.py:87 — The summary line names the two branches of the loop below it, so it narrates the code.

Deferred

An automatic run acts only on a blocking comment, or on one that takes lines away.
These stay for a person to decide.

  • r9-07 (thermo, minor) interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py:409derive_max_hours takes the rated power and the words that name that power as two separate parameters, and only the docstring keeps the two in agreement.
  • r9-08 (correctness, minor) docs/translation_mappings/translation-from-pypsa-to-sienna.md:1385 — The Link "Capacity Expansion Handling" section still states the rule this branch replaced, so the authoritative mapping document now describes the opposite of what _links.py does.
  • r9-14 (pointless, minor) interop/plugins/shared/plexos_pypsa_translations/_generators.py:161_carry_to_extensions says it puts values in the sidecar, but the body also writes a console warning about builds the translator left out.
  • r9-15 (pointless, minor) interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py:368 — The module docstring lists the guards rate_object applies, and this branch adds a guard the list does not name.

Addresses r10-01, r10-02, r10-03, r10-04, r10-05, r10-06, r10-07, r10-08,
r10-09, r10-10 and r10-12.

r10-01: the `base_power` event named `p_nom` as its source while the value came
from `p_nom_opt` or `p_nom_min`, so `decisions.md` gave a reader the wrong
attribute and the wrong number. `direct_translation` now takes a callable
`source_col`, so a translation whose expression reads a different column for
each row names the column that row was read from. `base_power`, `active_power`
and the hydro `active_power_limits` all use it, on generators, renewables,
hydro units and storage units.

r10-03: `_pick_capacity` states the three-way capacity choice one time.
`effective_p_nom` reads it over the column values, `capacity_attribute` reads
it over the column names, and `states_built_capacity` reads the same two tests
through `has_solved_capacity` and `has_capacity_floor`. `with_effective_p_nom`
writes the chosen column name into `_capacity_attribute`, which `rated_from`
reads, so `choose_capacity_attribute` goes.

r10-02: `fill_capacity_columns` fills the four capacity columns and
`fill_capacity_defaults` rates the two columns from them. Each mapping's
`fill_defaults` calls the first, and `_rows_in_scope` and `_map_links` call the
second, so one layer owns the rating.

r10-04: `DroppedBuild` held the same three fields as `SkippedComponent`, so it
goes and `warn_about_dropped_builds` calls `warn_about_skips`.

r10-10: `_CarrierGroup.capacity` had one value, so it goes with the
`CapacityColumns` export it needed.

r10-05, r10-06, r10-07, r10-08, r10-09 and r10-12: cut five docstrings and one
comment sentence back to the fact each one states.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automatic review-and-fix, round 10

Commit a4efa9d acts on the comments below.

Acted on

  • r10-01 (correctness, major) interop/plugins/shared/pypsa_sienna_translations/_generators.py:357 — The base_power event names p_nom as its source while the value it reports comes from p_nom_min or p_nom_opt, so decisions.md records the wrong source attribute and the wrong source number.
  • r10-02 (thermo, major) interop/plugins/steps/pypsa_to_sienna_map_components.py:255_rows_in_scope rates the two enrichment columns for the whole source table, and then each mapping's fill_defaults rates the same two columns again.
  • r10-03 (thermo, major) interop/plugins/shared/pypsa_sienna_translations/_shared.py:89choose_capacity_attribute writes in Python the same three-way choice that effective_p_nom writes in Polars, and states_built_capacity writes the same two tests a third time.
  • r10-04 (thermo, major) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:226DroppedBuild holds the same three fields as SkippedComponent, so this module states one record shape a second time.
  • r10-05 (pruning, minor) interop/plugins/shared/pypsa_sienna_translations/_shared.py:57 — The CapacityColumns docstring paraphrases the class name and still does not say what the four fields hold.
  • r10-06 (pruning, nit) interop/plugins/shared/pypsa_sienna_translations/_shared.py:65 — The second sentence of the POWER_CAPACITY comment argues for the bundle and predicts a Line bundle no code has.
  • r10-07 (pruning, minor) interop/plugins/shared/pypsa_sienna_translations/_shared.py:128 — The fill_capacity_defaults docstring restates the name and names neither column it adds.
  • r10-08 (pruning, minor) interop/plugins/shared/plexos_pypsa_translations/decisions.py:90 — The warn_about_groups docstring narrates the three statements below it.
  • r10-09 (pruning, minor) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:129 — The dropped_build comment repeats the DroppedBuild docstring in the same file.
  • r10-10 (pointless, minor) interop/plugins/steps/pypsa_to_sienna_map_components.py:125_CarrierGroup.capacity is a configuration point with one value, and its type is the only use of CapacityColumns outside its own package.
  • r10-12 (pointless, nit) interop/plugins/shared/pypsa_sienna_translations/_shared.py:66 — The second sentence of the POWER_CAPACITY comment argues for the design instead of stating a fact.

Deferred

An automatic run acts only on a blocking comment, or on one that takes lines away.

  • r10-11 (pointless, minor) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:202 — The class name UnpricedBuild says the opposite of what the class holds, which is a property that prices a build.
  • r10-13 (pruning, nit) interop/plugins/shared/plexos_pypsa_translations/decisions.py:64 — The SkipGroup docstring uses a metaphor and shorthand, so the reader cannot tell what groups the objects together.

Addresses r11-01, r11-02, r11-03, r11-04, r11-06, r11-07, r11-08, r11-09 and
r11-10.

r11-03: the `sienna-to-pypsa` leg wrote `p_nom_extendable` with no `p_nom_min`,
so PyPSA ignored the capacity the component already held. The next
`pypsa-to-sienna` hop then read it as a build the plan had not made and left it
out, which loses every extendable ThermalStandard, RenewableDispatch,
EnergyReservoirStorage and TwoTerminalGenericHVDCLine on a round trip. Each of
the four rows now states `base_power` as `p_nom_min`, and each reporter records
it. `LINKS_DESTINATION_SCHEMA` gains the `p_nom_min` column that needs.
`tests/features/pypsa_to_sienna_to_pypsa.feature` drives both hops over one
extendable generator, which is the chain that catches this.

r11-01: `fill_link_defaults` stated the four PyPSA capacity defaults by hand.
A Link names those columns as a Generator does, so it calls
`fill_capacity_columns` with `POWER_CAPACITY` like every other table.

r11-02: `direct_translation` took `source_col` as a string or a callable, and
had to reject a callable with no `expr` at run time. `row_source_translation`
now takes the per-row form with a required `expr`, and `direct_translation`
calls it with a constant, so the rejected pair cannot be written.

r11-04, r11-06 and r11-07: the `_find_unpriced_build` docstring, one paragraph
of `translation-from-plexos-to-pypsa.md` and three of its table rows all stated
the pricing rule before a stated zero counted as no price.

r11-08, r11-09 and r11-10: cut three docstrings that narrate the code below
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automatic review-and-fix, round 11

Commit ae62871 acts on the comments below.

Acted on

  • r11-01 (thermo, major) interop/plugins/shared/pypsa_sienna_translations/_links.py:93fill_link_defaults states the four PyPSA capacity defaults by hand, and fill_capacity_columns in the same package states the same four.
  • r11-02 (thermo, major) interop/plugins/shared/translation_runner.py:50 — The source_col union lets a caller pass a callable with no expr, so direct_translation must reject that pair at run time.
  • r11-03 (correctness, major) interop/plugins/shared/pypsa_sienna_translations/_shared.py:199 — The new candidate drop deletes every component that the sienna-to-pypsa leg wrote, because that leg restores p_nom_extendable without a p_nom_min or a p_nom_opt.
  • r11-04 (pruning, major) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:271 — The diff made this docstring wrong: _find_unpriced_build no longer tests only for a property a candidate leaves out.
  • r11-06 (pruning, major) docs/translation_mappings/translation-from-plexos-to-pypsa.md:746 — This paragraph states the pricing rule the branch replaced, so the authoritative mapping document describes behaviour the code does not have.
  • r11-07 (pruning, major) docs/translation_mappings/translation-from-plexos-to-pypsa.md:290 — These three table rows state the pricing rule the branch replaced, because a stated zero now drops the same candidate as a missing property.
  • r11-08 (pruning, minor) interop/plugins/shared/pypsa_sienna_translations/_shared.py:107 — The rated_from docstring paraphrases the name and states what the one line below it shows.
  • r11-09 (pruning, minor) interop/plugins/shared/pypsa_sienna_translations/_shared.py:88 — The _pick_capacity docstring names the three branches below it and the two callers below those.
  • r11-10 (pruning, minor) interop/plugins/shared/plexos_pypsa_translations/decisions.py:105 — The warn_about_skips sentence argues for doing both a record and a warning, and describes work this function does not do.

Deferred

An automatic run acts only on a blocking comment, or on one that takes lines away.

  • r11-05 (pruning, minor) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:202 — The UnpricedBuild docstring says stating the property is enough, but a stated zero prices no build.
  • r11-11 (pruning, nit) interop/plugins/shared/plexos_pypsa_translations/decisions.py:70 — The reason docstring shows a sentence that stops at , but the new outcome field means every warning continues past it.
  • r11-12 (pointless, minor) interop/plugins/shared/plexos_pypsa_translations/decisions.py:78SkippedComponent says the mapping did not translate the object, but dropped_build now holds one for an object the mapping does write.
  • r11-13 (pointless, nit) interop/plugins/shared/plexos_pypsa_translations/decisions.py:89 — The module docstring says this module records decisions as events, but it now also writes the console warning.

Addresses r12-01 to r12-12. Round 11 wrote `p_nom_min` for an extendable
component, and left three gaps.

r12-01 and r12-02: `_add_links` copies only the columns it names, and it named
no `p_nom_min`, so the link floor never reached the written network.
`decisions.md` stated the floor while the file held PyPSA's default of 0, and
the next `pypsa-to-sienna` hop dropped the link.

r12-03 and r12-04: `_renewable_row` wrote `p_nom_min` and `_record_renewable`
recorded no event for it, so the report named the floor of a thermal generator,
a storage unit and a link, but not of a renewable.

r12-05, r12-06 and r12-07: `_EXTENDABLE_FLOOR_DERIVATION` opened with
`base_power -> p_nom_min`, but the link event names
`active_power_limits_from.max` as its source and a Sienna HVDC line has no
`base_power`. Each event already names its own source field, so the prefix goes.

r12-08 and r12-09: `translation-from-pypsa-to-sienna.md` stated that v1 takes
`p_nom_opt` and drops the rest, two lines above the paragraph that says
`effective_p_nom` also reads `p_nom_min`.
`translation-from-sienna-to-pypsa.md` still said an extendable component comes
back with no floor, and its four tables named no `p_nom_min` row.

r12-10: `rated_translation` in `_shared.py` binds the factory the four
translation modules each built by hand.

r12-11 and r12-12: two feature comments repeat what the run itself prints and
what the steps beside them say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automatic review-and-fix, round 12

Commit 0631eb7 acts on every comment below. Nothing was deferred this round.

Three reviewers independently found the two gaps round 11 left: the link floor never
reached the written network, and a renewable's floor carried no event.

  • r12-01 (correctness, major) interop/plugins/sinks/emit_pypsa_network.py:259_add_links never writes p_nom_min, so the link capacity floor that map_transmission.py puts in the row is thrown away.
  • r12-02 (pointless, major) interop/plugins/steps/sienna_to_pypsa/map_transmission.py:294 — The Link p_nom_min the branch adds never reaches the written network, because _add_links lists it in neither required nor optional.
  • r12-03 (thermo, major) interop/plugins/steps/sienna_to_pypsa/map_generators.py:428_renewable_row writes p_nom_min, but _record_renewable records no event for that value.
  • r12-04 (correctness, major) interop/plugins/steps/sienna_to_pypsa/map_generators.py:428 — A renewable generator gets p_nom_min written with no translation event.
  • r12-05 (pruning, major) interop/plugins/shared/sienna_pypsa_translations/reporters.py:103_EXTENDABLE_FLOOR_DERIVATION opens with base_power -> p_nom_min, but the link event it is shared with names active_power_limits_from.max as its source.
  • r12-06 (correctness, minor) interop/plugins/shared/sienna_pypsa_translations/reporters.py:964 — The link event states the derivation base_power -> p_nom_min, but a Sienna HVDC line has no base_power.
  • r12-07 (pointless, minor) interop/plugins/shared/sienna_pypsa_translations/reporters.py:960 — The same derivation prefix names a field the link's own source field is not.
  • r12-08 (pruning, major) docs/translation_mappings/translation-from-pypsa-to-sienna.md:82 — This paragraph says the translation takes p_nom_opt and drops the expansion parameters, but the paragraph two lines below says effective_p_nom also reads p_nom_min.
  • r12-09 (pruning, major) docs/translation_mappings/translation-from-sienna-to-pypsa.md:465 — The authoritative mapping document for the Sienna to PyPSA leg is untouched and now describes behaviour the code no longer has.
  • r12-10 (thermo, major) interop/plugins/shared/pypsa_sienna_translations/_generators.py:244 — The _rated partial stands four times, because row_source_translation takes a callable that holds one value at every call site.
  • r12-11 (pruning, minor) tests/features/sienna_to_pypsa/generators.feature:170 — This comment repeats _EXTENDABLE_FLOOR_DERIVATION, which the same translate run writes into decisions.md.
  • r12-12 (pruning, minor) tests/features/pypsa_to_sienna_to_pypsa.feature:35 — This comment restates the two Gherkin steps it sits between.

…s floor

Addresses r13-01 to r13-06, r13-08, r13-09, r13-12, r13-13 and r13-14.

r13-06: a candidate Battery took its starting charge against the whole capacity
it may build, so a PyPSA solve had to build part of it to hold a charge the
translator invented. `RatedObject.running_power` gives the capacity the object
already has, and the stored energy reads against that. A battery that runs no
units now starts at 0 MWh, and a battery that already runs is unchanged.

r13-05: an extendable Line reached the written network with no `s_nom_min`, so
a solve could delete a line the Sienna system rates. It now carries the same
floor a generator, a storage unit and a link carry, through
`LINES_DESTINATION_SCHEMA`, `_add_lines` and `LineReporter.record_s_nom_min`.

r13-03: the link `p_nom_min` event gave `p_nom` as the value of
`active_power_limits_from.max`. The two differ wherever the sidecar states a
`p_max_pu`, so `_LinkPowerLimits` now carries `limit_max` and the event names it.

r13-04: `_extendable_floor` states the floor rule one time for a line and a
link, and `p_nom_min` is one field on each generator and storage mapping that
the record function and the row builder both read.

r13-01: `_links.py` built the capacity choice as its own expression while the
step had already written that value into `_effective_p_nom`. It reads the column.

r13-02: `rated_translation` returned `Callable[..., Translation]`, which stopped
mypy checking any argument at the eight call sites. It is a plain function now,
and each module binds it with `partial` as it binds `direct_translation`.

r13-08 and r13-09: eight rows of `translation-from-plexos-to-pypsa.md` stated
rules the code stopped following, and contradicted the prose of the same
document. `translation-from-sienna-to-pypsa.md` gains the `s_nom_min` row.

r13-12, r13-13 and r13-14: one docstring repeated the one below it, one counted
drops that have since grown, and the package exported two entry points onto one
enrichment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automatic review-and-fix, round 13

Commit dd51a39 acts on the comments below.

Acted on

  • r13-01 (thermo, major) interop/plugins/shared/pypsa_sienna_translations/_links.py:196 — The Link module builds the capacity choice as its own expression, but the step already wrote that value into the _effective_p_nom column.
  • r13-02 (thermo, major) interop/plugins/shared/pypsa_sienna_translations/_shared.py:113rated_translation returns Callable[..., Translation], which stops mypy from checking any argument at the eight call sites.
  • r13-03 (thermo, major) interop/plugins/shared/sienna_pypsa_translations/reporters.py:960 — The link p_nom_min event gives p_nom as the value of active_power_limits_from.max, but the two differ when the sidecar states a p_max_pu.
  • r13-04 (thermo, major) interop/plugins/steps/sienna_to_pypsa/map_generators.py:414 — The rule that an extendable component takes its base_power as p_nom_min stands twice per component.
  • r13-05 (correctness, major) interop/plugins/steps/sienna_to_pypsa/map_transmission.py:240 — An extendable Line reaches the written PyPSA network with no s_nom_min, so a solve can delete the line the Sienna system rates.
  • r13-06 (correctness, major) interop/plugins/shared/plexos_pypsa_translations/_batteries.py:129 — A candidate Battery gets a state_of_charge_initial computed from the whole capacity it may build, which forces a PyPSA solve to build part of it.
  • r13-08 (pruning, major) docs/translation_mappings/translation-from-plexos-to-pypsa.md:262 — The p_nom row is wrong: a generator that runs no units takes Max Capacity x Max Units Built, and the Rating rule no longer applies.
  • r13-09 (pruning, major) docs/translation_mappings/translation-from-plexos-to-pypsa.md:273 — These seven rows state a rule the code does not follow for a generator that already runs and prices no build.
  • r13-12 (pruning, minor) interop/plugins/shared/pypsa_sienna_translations/_shared.py:114 — The rated_translation docstring restates the docstring of row_source_translation, which it calls two lines below.
  • r13-13 (pointless, minor) interop/plugins/steps/pypsa_to_sienna_map_components.py:263 — The _scope_rules docstring names three drops the table shares, but the list now returns four.
  • r13-14 (pointless, nit) interop/plugins/shared/pypsa_sienna_translations/__init__.py:66 — The package exports both fill_capacity_defaults and with_effective_p_nom for one call site each.

Deferred

An automatic run acts only on a blocking comment, or on one that takes lines away.

  • r13-07 (correctness, minor) interop/plugins/steps/sienna_to_pypsa/map_generators.py:281 — A thermal generator whose sidecar states no committable gets committable False written with no translation event.
  • r13-10 (pruning, minor) docs/translation_mappings/translation-from-pypsa-to-sienna.md:84 — The sentence names four mappings that leave an unbuilt candidate out, but the link mapping applies the same drop.
  • r13-11 (pruning, nit) docs/translation_mappings/translation-from-sienna-to-pypsa.md:469 — The words 'of a solved network' name one of the two capacities the opposite direction writes into base_power.
  • r13-15 (pointless, nit) tests/features/plexos_to_pypsa/generators.feature:557 — The generator name Undiscounted_REZ stands for two opposite outcomes twenty lines apart.

… power

Addresses r14-01, r14-02, r14-04 to r14-09, r14-11, r14-13 to r14-15, r14-17
and r14-19.

r14-05: PyPSA reads `fom_cost` as a charge for the whole modelled horizon and
adds it to the annuitised build cost without scaling, so the PLEXOS `FO&M
Charge` in $/MW/yr was wrong on any horizon other than a year. On the two-day
horizon of the generator scenario the 15000 $/MW yearly charge stood beside a
564 $/MW build cost, so no candidate could ever be built. The charge now travels
in the sidecar as `fom_charge_per_mw_year`, with an event naming why.

r14-06: `_ramp_limit` divided by `base_power` with no guard, and this branch
makes a `ThermalStandard` with `base_power` 0.0 an ordinary output of the
opposite leg, so a round trip stopped on model data. It uses `per_unit_of`,
which the same file already uses three lines below.

r14-04: a candidate reservoir turbine started with the head reservoir's whole
Initial Volume although it runs no units, which forced a solve to build capacity
to hold it. It reads `running_power`, as the battery does.

r14-01: `extendable_floor` in `sienna_pypsa_translations/mapping.py` states the
floor rule one time for a generator, a renewable, a storage unit, a line and a
link.

r14-02: `rated_translation` only bound one argument on `row_source_translation`,
and each of the four call sites wrapped it in `partial` again. The call sites
bind that argument themselves.

r14-07, r14-08 and r14-09: five sections of
`translation-from-plexos-to-pypsa.md` stated rules the code stopped following,
about the starting energy of a battery and a reservoir, the `p_nom` of an object
that runs no units, and the `Rating` of a candidate.

r14-11, r14-13, r14-14, r14-15 and r14-17: five docstrings that narrate the code
below them or name one caller's expression.

r14-19: three feature steps asserted PyPSA's own default for `p_nom_min`, so
none of them could fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automatic review-and-fix, round 14

Commit 4ae593c acts on the comments below.

Acted on

  • r14-01 (thermo, major) interop/plugins/steps/sienna_to_pypsa/map_transmission.py:60 — One rule stands in five places: an extendable component keeps its rated capacity as the floor a build cannot take away.
  • r14-02 (thermo, major) interop/plugins/shared/pypsa_sienna_translations/_shared.py:113rated_translation only binds source_col_of=rated_from, and each of the four call sites wraps it in partial again.
  • r14-04 (correctness, major) interop/plugins/shared/plexos_pypsa_translations/_storage_hydro.py:220 — A candidate reservoir-hydro turbine starts with the head reservoir's whole Initial Volume although it runs no units.
  • r14-05 (correctness, major) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:168fom_cost carries the PLEXOS FO&M Charge in $/MW/yr, but PyPSA reads fom_cost as a charge for the whole modelled horizon.
  • r14-06 (correctness, major) interop/plugins/steps/sienna_to_pypsa/map_generators.py:157_ramp_limit divides by base_power with no guard, and a Sienna ThermalStandard with base_power 0.0 is an ordinary output of the opposite leg.
  • r14-07 (pruning, major) docs/translation_mappings/translation-from-plexos-to-pypsa.md:360 — The state_of_charge_initial row and the paragraph below it say the starting energy reads against p_nom x max_hours.
  • r14-08 (pruning, major) docs/translation_mappings/translation-from-plexos-to-pypsa.md:393 — The pumped-storage and Battery p_nom rows state x Units, but an object that runs no units takes x Max Units Built.
  • r14-09 (pruning, major) docs/translation_mappings/translation-from-plexos-to-pypsa.md:811 — The rule that a static Rating above Max Capacity x Units becomes the capacity no longer holds for a generator that runs no units.
  • r14-11 (pruning, minor) interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py:434 — The derive_state_of_charge_initial docstring repeats the signature and names a ceiling the battery caller no longer passes.
  • r14-13 (pruning, minor) interop/plugins/steps/sienna_to_pypsa/map_transmission.py:61 — The _extendable_floor docstring names the return value and narrates the one line below it.
  • r14-14 (pruning, minor) interop/plugins/steps/pypsa_to_sienna_map_components.py:262 — The first sentence of the _scope_rules docstring argues for one report per drop rather than stating a fact.
  • r14-15 (pruning, minor) interop/plugins/shared/plexos_pypsa_translations/constants.py:21 — The two EXT_* docstrings carry the same sentence twice.
  • r14-17 (pointless, minor) interop/plugins/shared/plexos_pypsa_translations/_batteries.py:131 — The battery passes running_power * max_hours as the clamp bound, but the callee docstring still says p_nom * max_hours.
  • r14-19 (pointless, minor) tests/features/plexos_to_pypsa/storage_units.feature:549p_nom_min is 0 asserts PyPSA's own default, so it passes whether or not the translator writes the value.

Deferred

An automatic run acts only on a blocking comment, or on one that takes lines away.
r14-20 also contradicts r14-01, which blocks and therefore wins.

  • r14-03 (thermo, minor) interop/plugins/shared/plexos_pypsa_translations/_batteries.py:110_derive_battery reaches through three objects for the unit size, then reads the running power through a property.
  • r14-10 (pruning, minor) docs/translation_mappings/translation-from-plexos-to-pypsa.md:943 — The gloss that is, / (Max Capacity x Units) states an old reading of p_nom.
  • r14-12 (pruning, nit) interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py:354 — The running_power docstring says again what the name says, and does not name the mistake it protects against.
  • r14-16 (pruning, nit) docs/translation_mappings/translation-from-sienna-to-pypsa.md:332 — The s_nom_min row writes the system base as words while every other row writes it as 100.
  • r14-18 (pointless, nit) interop/plugins/shared/plexos_pypsa_translations/decisions.py:89warn_about_groups is a public name whose only caller is warn_about_skips in the same file.
  • r14-20 (pointless, nit) interop/plugins/steps/sienna_to_pypsa/map_transmission.py:60_extendable_floor wraps a one-line conditional and its docstring restates the derivation the same call sites write.

…_cost column

Addresses r15-01, r15-02, r15-05, r15-07 to r15-13.

r15-05 (critical): a Sienna ThermalStandard whose `base_power` is 0.0 raised
`ZeroDivisionError` in `_map_generators` and stopped the whole translation. This
branch makes a zero `base_power` an ordinary output of the opposite leg, where a
solve builds none of an extendable generator, so a round trip hit it. The three
other divisions in the file already went through `per_unit_of`; this one did not.

r15-01, r15-07, r15-09 and r15-13: round 14 sent the yearly charge to the
sidecar and left `fom_cost` in two destination schemas and two sink lists with
nothing to write it, so both tables carried an always-null column and the
schema told a reader the translator fills a PyPSA field it does not fill.

r15-02: `RatedObject` stated `name` and `properties` a second time, although its
`candidate` field already held both, so a reader could not tell whether the two
could differ. It reads them from the candidate, and `unit_size` gives
`_derive_battery` a name for the value it reached three objects deep for.

r15-10: round 13 changed the clamp bound to the energy of the units the object
already runs, but `_CLAMPED_DERIVATION`, which `decisions.md` carries, still
named `p_nom * max_hours`. For a candidate turbine those differ by the whole
capacity it may build.

r15-08: the design record said the translator writes the PyPSA `fom_cost`
column, which no part of the branch now fills.

r15-11 and r15-12: three feature comments each repeated a rule already stated in
the same file or in the derivation the run itself prints.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automatic review-and-fix, round 15

Commit e9ff964 acts on the comments below. This round found the run's first
critical: a zero base_power, which this branch makes an ordinary output of the
PyPSA to Sienna leg, stopped the Sienna to PyPSA leg with a ZeroDivisionError.

Acted on

  • r15-05 (correctness, critical) interop/plugins/steps/sienna_to_pypsa/map_generators.py:92 — A Sienna ThermalStandard whose base_power is 0.0 raises ZeroDivisionError here and stops the whole translation.
  • r15-01 (thermo, major) interop/plugins/shared/pypsa_constants.py:489 — No code writes fom_cost, so the two destination schemas and the two sink lists declare a column that stays null.
  • r15-09 (pruning, major) interop/plugins/shared/pypsa_constants.py:482 — The comment says the expansion columns are null only for a component whose capacity the source fixes, but fom_cost is null for every component.
  • r15-13 (pointless, minor) interop/plugins/shared/pypsa_constants.py:489 — The branch adds a fom_cost column to two schemas and to the sink, but no code writes a value into it.
  • r15-07 (correctness, minor) interop/plugins/shared/pypsa_constants.py:489 — No code writes fom_cost any more, so the column stands in two schemas and two sink lists with nothing behind it.
  • r15-02 (thermo, major) interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py:350RatedObject states name, properties and p_nom a second time, because its candidate field already holds all three.
  • r15-10 (pruning, major) interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py:91 — The branch changed the clamp bound, so _CLAMPED_DERIVATION, which decisions.md carries, names a bound the code no longer applies.
  • r15-08 (pruning, major) docs/specs/2026-09-08-plexos-sienna-investments-design.md:61 — The design record says the translator writes the PyPSA fom_cost column, but the branch writes the yearly charge to the sidecar.
  • r15-11 (pruning, minor) tests/features/plexos_to_pypsa/storage_units.feature:551 — This comment states the same rule, over the same two numbers, as a comment earlier in the same file.
  • r15-12 (pruning, minor) tests/features/plexos_to_pypsa/storage_units.feature:603 — This comment is the fourth copy of the candidate p_nom rule.

Deferred

An automatic run acts only on a blocking comment, or on one that takes lines away.

  • r15-03 (thermo, minor) interop/plugins/steps/pypsa_to_sienna_map_components.py:515_map_links repeats the table fill and the candidate drop that _rows_in_scope and _scope_rules already hold.
  • r15-04 (thermo, minor) interop/plugins/shared/pypsa_sienna_translations/_generators.py:101 — The step fills the four capacity columns for the whole source table, and then each of the five mapping modules fills the same four again.
  • r15-06 (correctness, minor) interop/plugins/shared/plexos_pypsa_translations/_expansion.py:245 — The warning and the decisions.md note say the candidate states no Build Cost when the candidate states a Build Cost of zero.

…xists

Addresses r16-01, r16-02, r16-05 to r16-10 and r16-12.

r16-06: `has_capacity_floor` read PyPSA `p_nom_min` as capacity a component
already holds, but PyPSA reads `p_nom_min` as the lower bound of the build, which
a user also sets to force a minimum build on a candidate nobody has built. A
generator with `p_nom` 0, `p_nom_min` 500 and no `p_nom_opt` reached Sienna as a
500 MVA plant. `capacity_floor` caps the bound by `p_nom`, so a brownfield plant
keeps its floor and a forced build is left out as a candidate.

r16-05: `build_hydro_ts_associations` divided by `efficiency_dispatch` with no
guard, and `fill_defaults` replaces only a null or a NaN, so a stated 0 stopped
the whole translation.

r16-01: every caller passed `POWER_CAPACITY`, so the `columns` parameter on nine
functions had one value. The four column names are module constants now.

r16-02: `ExpansionExtension` holds the three sidecar fields that
`GeneratorExtension` and `StorageExtension` each declared with the same three
comments.

r16-07 and r16-08: the design record said the operations steps skip every
extendable component, and the `Units` row of
`translation-from-plexos-to-sienna.md` still stated the retirement rule without
the candidate qualifier its sibling document carries.

r16-09, r16-10 and r16-12: two feature comments repeated another artifact, and
three storage modules each built a logger no line reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automatic review-and-fix, round 16

Commit 44fde5f acts on the comments below.

Acted on

  • r16-06 (correctness, major) interop/plugins/shared/pypsa_sienna_translations/_shared.py:84has_capacity_floor reads PyPSA p_nom_min as capacity the component already holds, but p_nom_min is the lower bound of the build.
  • r16-05 (correctness, major) interop/plugins/shared/pypsa_sienna_translations/_hydro.py:205build_hydro_ts_associations divides by efficiency_dispatch with no zero guard, so a source StorageUnit that states 0 stops the whole translation.
  • r16-01 (thermo, major) interop/plugins/shared/pypsa_sienna_translations/_shared.py:64 — Every caller passes POWER_CAPACITY, so the columns parameter on nine functions has one value.
  • r16-02 (thermo, major) interop/core/extensions.py:159StorageExtension declares the same three expansion fields, with the same three comments, as GeneratorExtension.
  • r16-07 (pruning, major) docs/specs/2026-09-08-plexos-sienna-investments-design.md:86 — The design record states that the operations steps skip every extendable component, but the code keeps one that states a solved capacity or a floor.
  • r16-08 (pruning, major) docs/translation_mappings/translation-from-plexos-to-sienna.md:446 — The Units row states the old retirement rule, because a Units of zero beside a Max Units Built is now a candidate.
  • r16-09 (pruning, minor) tests/features/plexos_to_pypsa/generators.feature:513 — This comment is a third copy of the reason a yearly charge travels in the sidecar.
  • r16-10 (pruning, minor) tests/features/plexos_to_pypsa/generators.feature:517 — This comment repeats the field comments on GeneratorExtension.
  • r16-12 (pointless, nit) interop/plugins/shared/plexos_pypsa_translations/_storage_units.py:68 — Three PLEXOS storage modules each build a module logger that no line reads.

Deferred

An automatic run acts only on a blocking comment, or on one that takes lines away.

  • r16-03 (thermo, minor) interop/plugins/shared/pypsa_sienna_translations/_generators.py:408GENERATOR_APL builds by hand the event that row_source_translation builds, and only the derivation text changes per row.
  • r16-11 (pointless, minor) interop/plugins/shared/plexos_pypsa_translations/decisions.py:189 — The mapped_fields docstring says a field declared without maps_to is skipped, but a field declared with holds recurses.
  • r16-13 (pointless, nit) interop/plugins/shared/plexos_pypsa_translations/_batteries.py:93_SOC_FROM_PERCENT_DERIVATION writes out the whole text of _STORED_ENERGY_DERIVATION.

One thermo comment, on the double capacity fill in pypsa_to_sienna_map_components.py,
repeated r15-04 and was dropped rather than put a second time.

Addresses r17-01, r17-02, r17-05 to r17-12.

r17-01 and r17-05 (critical): round 16 guarded the hydro budget division by
answering 0.0, which did not remove the division. The h5 sink divides the inflow
series by that scaling factor, so numpy wrote `inf` into
`system_time_series_storage.h5` with only a warning, and `decisions.md` said
nothing. A unit with no capacity to convert its inflow with, or no dispatch
efficiency, is left out and reported instead, through the skip mechanism the
mapping already has. `ComponentMapping.skips` holds a sequence, because one
table now states two rules. A stated `efficiency_dispatch` of 0 never reaches
this code through the shipped pipeline, because `pypsa_storage_units` rejects it
first; a `p_nom` of 0 does, and the new scenario drives that case.

r17-06: a candidate's static `Rating` was read against the whole capacity it may
build and had no ceiling, so a `Rating` of 150 against a buildable 100 MW wrote
a `p_max_pu` of 1.5, and a `Rating` equal to `Max Capacity` derated a two-unit
candidate to 0.5. PLEXOS states a Rating for one unit, so a candidate reads it
against one unit's `Max Capacity`, held at full availability.

r17-02: `effective_p_nom` and `capacity_attribute` stated the same three-way rule
in two chains that could disagree, and `decisions.md` carries the attribute name
against every component. `capacity_choice` gives both from one chain.

r17-07 and r17-08: the note `decisions.md` carries for a dropped candidate said
`p_nom_min is 0`, which is false for the forced-build case round 16 added, and
the mapping document still stated the rule round 16 replaced.

r17-09, r17-10, r17-11 and r17-12: a docstring naming an unreachable case, a
scenario description copying a docstring it already disagreed with, three
constants with one reader each, and a module summary that named none of what the
module now holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@thomaschristopherking thomaschristopherking left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automatic review-and-fix, round 17

Commit c0f08d0 acts on the comments below.

Round 16's hydro guard did not remove the division: it moved it into the h5 sink,
which divides the inflow series by the scaling factor. A unit that cannot be scaled
is now left out and reported. A stated efficiency_dispatch of 0 never reaches that
code through the shipped pipeline, because pypsa_storage_units rejects it first; a
p_nom of 0 does, and the new scenario drives that case.

Acted on

  • r17-01 (thermo, critical) interop/plugins/shared/pypsa_sienna_translations/_hydro.py:157_hydro_budget_scale returns a scaling factor of 0.0, and the h5 sink divides the inflow series by that factor.
  • r17-05 (correctness, major) interop/plugins/shared/pypsa_sienna_translations/_hydro.py:160 — A hydro_budget scale of zero is used as a divisor by the h5 sink, so the stored budget becomes infinity instead of nothing.
  • r17-06 (correctness, major) interop/plugins/shared/plexos_pypsa_translations/_generator_derivation.py:125 — A candidate generator's Rating is read against the whole capacity it may build, so its p_max_pu is no longer held at or below 1.
  • r17-02 (thermo, major) interop/plugins/shared/pypsa_sienna_translations/_shared.py:90effective_p_nom and capacity_attribute state the same three-way capacity rule twice.
  • r17-07 (pruning, major) interop/plugins/shared/pypsa_sienna_translations/_shared.py:194 — The note decisions.md carries for a dropped candidate says p_nom_min is 0, but the drop now turns on the lower of p_nom_min and p_nom.
  • r17-08 (pruning, major) docs/translation_mappings/translation-from-pypsa-to-sienna.md:84 — The authoritative mapping document states the old rule for an extendable component in an unsolved network.
  • r17-09 (pruning, major) interop/plugins/shared/pypsa_sienna_translations/_hydro.py:158 — The docstring names a case the code cannot reach, because fill_hydro_defaults fills a missing efficiency to 1.0.
  • r17-10 (pruning, minor) tests/features/pypsa_to_sienna/thermal_generators.feature:208 — The scenario description copies the capacity_floor docstring word for word, and the two already disagree.
  • r17-11 (pointless, minor) interop/plugins/shared/plexos_pypsa_translations/constants.py:20 — The three EXT_*_FIELD constants sit in the shared module, but only _expansion.py reads them, once each.
  • r17-12 (pointless, minor) interop/plugins/shared/pypsa_sienna_translations/_shared.py:3 — The module docstring lists three things this module holds, and the branch added a fourth the list does not name.

Deferred

  • r17-04 (thermo, minor) interop/plugins/shared/plexos_pypsa_translations/_generator_derivation.py:131SourceGenerator.p_nom builds a Decision, keeps the number and drops the sources, and _p_nom builds the same Decision again.

One thermo comment, on the console warning written per dropped generator, repeated
r6-06 and was dropped rather than put a second time.

@thomaschristopherking thomaschristopherking removed the review-fix-running An automated review-and-fix cycle is running on this branch label Sep 10, 2026
The eleven comments the four reviewers raised in round 17, with the verdict each
one got. Commit `c0f08d0` acts on the ten the run accepted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thomaschristopherking

Copy link
Copy Markdown
Contributor Author

Superseded by #14, which squashes this whole stack into one branch off main. Closing this in favour of it.

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