From 8416f857cab003b6c02389d60a3e3836dae54387 Mon Sep 17 00:00:00 2001 From: "Thomas C. King" Date: Fri, 11 Sep 2026 09:17:22 +0000 Subject: [PATCH] Carry a PLEXOS Constraint through the extensions sidecar A PLEXOS `Constraint` holds a weighted sum over the objects it names to a right-hand side, and that is how a model states an energy budget, a running-hour cap, an RPS target and an emission target. The AEMO 2024 ISP file carries 186 of them. `map_constraints` reported every one as not carried and the reading stopped there, so nothing downstream could apply a constraint even in principle. A PyPSA `GlobalConstraint` limits one carrier over the whole horizon and cannot name a set of components, so no shape of `Constraint` fits it. The concept survives a hop through PyPSA only in the sidecar, which now carries each `Constraint` it can read: the sense, the period its right-hand side applies over, the limit, and each term of the weighted sum with the class its object belongs to and the property that weights it. `pypsa-to-sienna` relays each record on, because a hop that cannot apply a constraint must not be the hop that loses it. Signed-off-by: Thomas C. King --- .../translation-from-plexos-to-pypsa.md | 41 +++-- interop/core/extensions.py | 69 +++++++++ .../plexos_pypsa_translations/_constraints.py | 144 +++++++++++++++--- .../steps/plexos_to_pypsa/map_constraints.py | 4 +- .../steps/pypsa_to_sienna_map_components.py | 14 ++ .../plexos_to_pypsa/constraints.feature | 66 ++++++-- 6 files changed, 291 insertions(+), 47 deletions(-) diff --git a/docs/translation_mappings/translation-from-plexos-to-pypsa.md b/docs/translation_mappings/translation-from-plexos-to-pypsa.md index 33b5350..d9f7a5c 100644 --- a/docs/translation_mappings/translation-from-plexos-to-pypsa.md +++ b/docs/translation_mappings/translation-from-plexos-to-pypsa.md @@ -31,7 +31,8 @@ It gives the source of each field. | [`Market`](#market--generator) | An import `Generator` | | [`Reserve`](#reserve--extensions-sidecar) | No component. The translator carries it to the reserves sidecar, but nothing applies it. | | [Region `VoLL`](#load-shedding) | No component in the two faithful pipelines. In `plexos-to-pypsa-monte-carlo-reliability`, a load shedding `Generator` at each bus. | -| `Zone`, `Interface`, `Transformer`, `Constraint`, `Waterway`, `Decision Variable` | [Not translated](#not-translated) | +| [`Constraint`](#constraint--extensions-sidecar) | No component. The translator carries it to the sidecar, but nothing applies it. | +| `Zone`, `Interface`, `Transformer`, `Waterway`, `Decision Variable` | [Not translated](#not-translated) | | `Transmission`, `ST`/`MT Schedule`, `PASA`, `Production`, `Performance`, `Stochastic`, `Report`, `Diagnostic`, `System`, `List` | Not translated. These are solver settings, not model data. | ## Reading the tables @@ -1011,7 +1012,7 @@ reservoir and no head reservoir. | `Interface` | Nothing applies the group flow limits. Thus the dispatch can be more than a transfer limit that your PLEXOS model obeys. | | `Transformer` | The translator does not carry it. | | `Reserve` requirements | Nothing applies them. The generators that contribute can operate at full output. The translator does carry the reserves. Refer to [`Reserve`](#reserve--extensions-sidecar). | -| `Constraint` | Nothing applies the custom constraints. This includes the energy budgets, the running hour limits, the RPS targets and the emission targets. The translator reports every one. Refer to [`Constraint`](#constraint). | +| `Constraint` | Nothing applies the custom constraints. This includes the energy budgets, the running hour limits, the RPS targets and the emission targets. The translator reports every one, and carries each one it can read to the extensions sidecar. Refer to [`Constraint`](#constraint--extensions-sidecar). | | `Waterway` | The cascade route between reservoirs is lost. Each reservoir is independent. | | `Decision Variable` | The translator does not carry it. | | Emission caps | Nothing applies them. Only the carbon price goes into the cost. | @@ -1020,7 +1021,7 @@ reservoir and no head reservoir. | Ancillary service and demand response pseudo-generators | The translator skips nothing. Refer to [`Generator`](#generator--generator). | | Gas, heat and water networks | The translator accepts electricity only. | -## `Constraint` +## `Constraint` → extensions sidecar A PLEXOS `Constraint` holds a weighted sum over the objects it names to a right-hand side. It weights each object by a coefficient on the membership — `Generation Coefficient`, @@ -1030,14 +1031,32 @@ group of hydro units and an annual running hour cap on a group of peakers are bo this way. A PyPSA `GlobalConstraint` limits one **carrier** over the whole horizon, and it has no way -to name a set of components. Thus no shape of `Constraint` fits it, and the translator -carries none of them. - -It does report all of them. Every right-hand side a `Constraint` states becomes a not -mapped entry against the object that states it, giving the value, the sense, and each term -of the weighted sum: the object, the class it belongs to, and the coefficient weighting it. -A `Constraint` stating no right-hand side is reported against the object itself. One -warning names a few of them and counts the rest. +to name a set of components. Thus no shape of `Constraint` fits it, and the network file +holds none of them. + +Each one the translator can read travels in the `extensions.json` file adjacent to the +network instead, as a `constraint` record in framework-neutral terms. The contract is in +`interop/core/extensions.py`. Nothing applies the limit, in the network or in the solve; +the sidecar carries it for a program that decides to use it, and for a later hop into a +framework that can express it. + +| Sidecar field | From | +| --- | --- | +| `name` | `Constraint.name` | +| `sense` | `Sense`, as the inequality it holds in: `<=`, `==` or `>=` | +| `limits` | One entry per right-hand side the `Constraint` states, each giving the value, the span it applies over (`horizon`, `hour`, `day`, `week`, `month` or `year`) and the unit the model stated it in | +| `members` | One entry per object the sum names, each giving the name, the PLEXOS class it belongs to, the coefficient and the property that states the coefficient | +| `applies_to_expansion_plan` | `Include in LT Plan` | + +A `Constraint` that states no sense, a `Sense` other than `-1`, `0` or `1`, or no +right-hand side at all, states no inequality to carry. The translator leaves that one out +and reports it, naming the `Sense` it could not read where that is the reason. + +The report carries all of them either way. Every right-hand side a `Constraint` states +becomes a not mapped entry against the object that states it, giving the value, the sense, +and each term of the weighted sum: the object, the class it belongs to, and the coefficient +weighting it. A `Constraint` stating no right-hand side is reported against the object +itself. One warning names a few of them and counts the rest. **Read that section of the report before you trust the dispatch.** A model that caps hydro energy or peaker running hours with a `Constraint` gives a translated network in which diff --git a/interop/core/extensions.py b/interop/core/extensions.py index cb6ddbf..df70044 100644 --- a/interop/core/extensions.py +++ b/interop/core/extensions.py @@ -51,6 +51,7 @@ class ExtensionKind(StrEnum): # PLEXOS Constraint. PyPSA's GlobalConstraint limits one carrier over the whole horizon # and cannot name a set of components, which is why the concept needs the sidecar to # survive a hop through it. Sienna has no equivalent either. + CONSTRAINT = "constraint" NETWORK = "network" # PyPSA network-level attributes. No Sienna or PLEXOS equivalent. @@ -84,6 +85,25 @@ class ReserveKind(StrEnum): UNKNOWN = "unknown" +class ConstraintSense(StrEnum): + """Which way a constraint holds its weighted sum against the right-hand side.""" + + AT_MOST = "<=" # PLEXOS Sense -1 + EXACTLY = "==" # PLEXOS Sense 0 + AT_LEAST = ">=" # PLEXOS Sense 1 + + +class ConstraintPeriod(StrEnum): + """The span one right-hand side applies over.""" + + HORIZON = "horizon" # PLEXOS RHS + HOUR = "hour" # PLEXOS RHS Hour + DAY = "day" # PLEXOS RHS Day + WEEK = "week" # PLEXOS RHS Week + MONTH = "month" # PLEXOS RHS Month + YEAR = "year" # PLEXOS RHS Year + + class ExtensionRecord(BaseModel): """One component's record. ``name`` is the identifier in every framework.""" @@ -204,6 +224,45 @@ class ReserveExtension(ExtensionRecord): is_mutually_exclusive: bool | None = None +class ConstraintMember(BaseModel): + """One object a constraint weights, and the coefficient it is weighted by. + + Two classes can hold an object of the same name and a constraint can weight a generator + and an emission alike, so the member carries the class its name belongs to. The + coefficient is absent where the constraint names the object without weighting it. + """ + + model_config = ConfigDict(extra="forbid") + + name: str + member_class: str # PLEXOS Generator, Line, Emission and the rest + coefficient: float | None = None + # PLEXOS names the coefficient per class: Generation Coefficient, Flow Coefficient, and + # so on. The name says which quantity of the member the coefficient weights. + coefficient_property: str | None = None + + +class ConstraintLimit(BaseModel): + """One right-hand side the weighted sum is held to, and the span it applies over.""" + + model_config = ConfigDict(extra="forbid") + + period: ConstraintPeriod + value: float + unit: str | None = None # the unit the source stated the limit in + + +class ConstraintExtension(ExtensionRecord): + """A weighted sum over named objects, held to one or more right-hand sides.""" + + sense: ConstraintSense | None = None + limits: list[ConstraintLimit] = [] + members: list[ConstraintMember] = [] + # PLEXOS Include in LT Plan: whether the expansion plan has to meet the constraint as + # well as the dispatch. + applies_to_expansion_plan: bool | None = None + + class NetworkExtension(ExtensionRecord): """The model file's own attributes. PyPSA only: neither Sienna nor PLEXOS has these.""" @@ -224,6 +283,7 @@ class Extensions(BaseModel): controllable_line: list[ControllableLineExtension] = [] storage: list[StorageExtension] = [] reserve: list[ReserveExtension] = [] + constraint: list[ConstraintExtension] = [] network: list[NetworkExtension] = [] @@ -235,6 +295,7 @@ class Extensions(BaseModel): ExtensionKind.CONTROLLABLE_LINE: ControllableLineExtension, ExtensionKind.STORAGE: StorageExtension, ExtensionKind.RESERVE: ReserveExtension, + ExtensionKind.CONSTRAINT: ConstraintExtension, ExtensionKind.NETWORK: NetworkExtension, } @@ -415,6 +476,10 @@ def record_for( staged: StagedExtensions, kind: Literal[ExtensionKind.RESERVE], name: str ) -> ReserveExtension | None: ... @overload +def record_for( + staged: StagedExtensions, kind: Literal[ExtensionKind.CONSTRAINT], name: str +) -> ConstraintExtension | None: ... +@overload def record_for( staged: StagedExtensions, kind: Literal[ExtensionKind.NETWORK], name: str ) -> NetworkExtension | None: ... @@ -506,6 +571,10 @@ def read(self, kind: Literal[ExtensionKind.STORAGE]) -> ExtensionLookup[StorageE @overload def read(self, kind: Literal[ExtensionKind.RESERVE]) -> ExtensionLookup[ReserveExtension]: ... @overload + def read( + self, kind: Literal[ExtensionKind.CONSTRAINT] + ) -> ExtensionLookup[ConstraintExtension]: ... + @overload def read(self, kind: Literal[ExtensionKind.NETWORK]) -> ExtensionLookup[NetworkExtension]: ... def read(self, kind: ExtensionKind) -> ExtensionLookup[Any]: diff --git a/interop/plugins/shared/plexos_pypsa_translations/_constraints.py b/interop/plugins/shared/plexos_pypsa_translations/_constraints.py index 1f7f287..55947e4 100644 --- a/interop/plugins/shared/plexos_pypsa_translations/_constraints.py +++ b/interop/plugins/shared/plexos_pypsa_translations/_constraints.py @@ -1,11 +1,15 @@ -"""PLEXOS Constraint -> the translation report. +"""PLEXOS Constraint -> extensions sidecar. A PLEXOS Constraint holds a weighted sum over the objects it names to a right-hand side. It weights each object by a coefficient stated on the membership, and it may state the right-hand side over an hour, a day, a week, a month, a year, or the whole horizon. PyPSA's GlobalConstraint limits one carrier over the whole horizon and has no way to name -a set of components, so no shape of Constraint has a home in the network file. +a set of components, so no shape of Constraint has a home in the network file. Each one is +carried into the extensions sidecar instead, in framework-neutral terms (see +``interop/core/extensions.py``), so a later hop into a framework that can express it still +has the limit. A Constraint stating no sense, or no right-hand side at all, says too little +to carry, so it is left out and reported. """ from __future__ import annotations @@ -13,6 +17,15 @@ import logging from dataclasses import dataclass +from interop.core.extensions import ( + ConstraintExtension, + ConstraintLimit, + ConstraintMember, + ConstraintPeriod, + ConstraintSense, + ExtensionKind, + append_extensions, +) from interop.core.pipeline import State from interop.core.reporting import ScopedRecorder from interop.plugins.shared.plexos_constants import ( @@ -20,6 +33,7 @@ PlexosObjectCol, PlexosProperty, PlexosResolvedTable, + is_plexos_true, ) from interop.plugins.shared.plexos_pypsa_translations._shared import ( ClassMember, @@ -39,23 +53,36 @@ log = logging.getLogger(__name__) +_CARRIED_NOTE = ( + "constraint carried to the extensions sidecar; PyPSA's GlobalConstraint cannot hold a " + "weighted sum over the objects a Constraint names, so the network file itself does not " + "limit them" +) _NOT_CARRIED_NOTE = ( - "a Constraint holds a weighted sum over the objects it names to its right-hand side, " - "which PyPSA's GlobalConstraint cannot express, so the limit is not carried" + "this Constraint states no sense, or no right-hand side, so it holds no inequality to " + "carry to the extensions sidecar" +) +_UNREADABLE_SENSE_NOTE = ( + "this Constraint states a Sense that is not -1, 0 or 1, so the translator cannot read the " + "inequality it holds in and carries nothing to the extensions sidecar" ) # PLEXOS states which way a Constraint binds as one integer. -_SENSES: dict[float, str] = {-1.0: "<=", 0.0: "==", 1.0: ">="} +_SENSES: dict[float, ConstraintSense] = { + -1.0: ConstraintSense.AT_MOST, + 0.0: ConstraintSense.EXACTLY, + 1.0: ConstraintSense.AT_LEAST, +} _UNSTATED_SENSE = "unstated" -_RIGHT_HAND_SIDES = ( - PlexosProperty.RHS, - PlexosProperty.RHS_HOUR, - PlexosProperty.RHS_DAY, - PlexosProperty.RHS_WEEK, - PlexosProperty.RHS_MONTH, - PlexosProperty.RHS_YEAR, -) +_PERIODS: dict[str, ConstraintPeriod] = { + PlexosProperty.RHS: ConstraintPeriod.HORIZON, + PlexosProperty.RHS_HOUR: ConstraintPeriod.HOUR, + PlexosProperty.RHS_DAY: ConstraintPeriod.DAY, + PlexosProperty.RHS_WEEK: ConstraintPeriod.WEEK, + PlexosProperty.RHS_MONTH: ConstraintPeriod.MONTH, + PlexosProperty.RHS_YEAR: ConstraintPeriod.YEAR, +} _NO_COEFFICIENT = "no coefficient" @@ -76,19 +103,34 @@ class _Term: @dataclass(frozen=True) class _Constraint: name: str - sense: str + sense: ConstraintSense | None + # What the model states, kept so a code the translator cannot read reports as itself. + stated_sense: float | None terms: tuple[_Term, ...] right_hand_sides: dict[str, float] units: dict[str, str | None] + applies_to_expansion_plan: bool | None + + +@dataclass(frozen=True) +class _Outcome: + constraint: _Constraint + record: ConstraintExtension | None def map_constraints(state: State, recorder: ScopedRecorder) -> None: constraints = _read_constraints(state) if not constraints: return + outcomes = [_Outcome(constraint, _carry(constraint)) for constraint in constraints] reporter = SourceReporter(recorder) - for constraint in constraints: - _record(reporter, constraint) + for outcome in outcomes: + _record(reporter, outcome) + append_extensions( + state.destination_extensions, + ExtensionKind.CONSTRAINT, + [outcome.record for outcome in outcomes if outcome.record is not None], + ) _warn(constraints) @@ -120,13 +162,15 @@ def _read_one( return _Constraint( name=name, sense=_read_sense(stated), + stated_sense=stated.get(PlexosProperty.SENSE), terms=_build_terms(members, coefficients), right_hand_sides={ property_name: stated[property_name] - for property_name in _RIGHT_HAND_SIDES + for property_name in _PERIODS if property_name in stated }, units=units.get(name, {}), + applies_to_expansion_plan=_read_plan_flag(stated), ) @@ -159,13 +203,49 @@ def _constraint_names(state: State) -> list[str]: return names -def _read_sense(stated: dict[str, float]) -> str: +def _read_sense(stated: dict[str, float]) -> ConstraintSense | None: code = stated.get(PlexosProperty.SENSE) - return _UNSTATED_SENSE if code is None else _SENSES.get(code, _UNSTATED_SENSE) + return None if code is None else _SENSES.get(code) + + +def _read_plan_flag(stated: dict[str, float]) -> bool | None: + flag = stated.get(PlexosProperty.INCLUDE_IN_LT_PLAN) + return None if flag is None else is_plexos_true(flag) + + +def _carry(constraint: _Constraint) -> ConstraintExtension | None: + """The sidecar record, or None where the constraint states too little to hold anything to.""" + if constraint.sense is None or not constraint.right_hand_sides: + return None + return ConstraintExtension( + name=constraint.name, + sense=constraint.sense, + limits=[ + ConstraintLimit( + period=_PERIODS[property_name], + value=value, + unit=constraint.units.get(property_name), + ) + for property_name, value in constraint.right_hand_sides.items() + ], + members=[_member(term) for term in constraint.terms], + applies_to_expansion_plan=constraint.applies_to_expansion_plan, + ) + + +def _member(term: _Term) -> ConstraintMember: + return ConstraintMember( + name=term.member.name, + member_class=term.member.member_class, + coefficient=term.coefficient, + coefficient_property=term.coefficient_property, + ) -def _record(reporter: SourceReporter, constraint: _Constraint) -> None: - note = f"{_NOT_CARRIED_NOTE}. {_describe(constraint)}" +def _record(reporter: SourceReporter, outcome: _Outcome) -> None: + constraint = outcome.constraint + carried = _CARRIED_NOTE if outcome.record is not None else _not_carried_note(constraint) + note = f"{carried}. {_describe(constraint)}" if not constraint.right_hand_sides: reporter.record_dropped(_source(constraint.name, None, None), note) return @@ -180,16 +260,29 @@ def _source( return SourceValue(PlexosClass.CONSTRAINT, name, attribute, value, unit) +def _not_carried_note(constraint: _Constraint) -> str: + if constraint.sense is None and constraint.stated_sense is not None: + return _UNREADABLE_SENSE_NOTE + return _NOT_CARRIED_NOTE + + def _describe(constraint: _Constraint) -> str: """The sense the Constraint binds in, and the weighted sum it binds.""" + sense = _sense_text(constraint) if not constraint.terms: - return f"Sense {constraint.sense}, over no objects" + return f"Sense {sense}, over no objects" return ( - f"Sense {constraint.sense} over {len(constraint.terms)} term(s): " + f"Sense {sense} over {len(constraint.terms)} term(s): " f"{name_a_few(_describe_term(term) for term in constraint.terms)}" ) +def _sense_text(constraint: _Constraint) -> str: + if constraint.sense is not None: + return constraint.sense + return _UNSTATED_SENSE if constraint.stated_sense is None else str(constraint.stated_sense) + + def _describe_term(term: _Term) -> str: if term.coefficient is None: return f"{term.member.member_class} {term.member.name} with {_NO_COEFFICIENT}" @@ -201,8 +294,9 @@ def _describe_term(term: _Term) -> str: def _warn(constraints: list[_Constraint]) -> None: log.warning( - "plexos: %s Constraint(s) limit what the model may dispatch, and PyPSA has no home " - "for any of them, so none is enforced: %s", + "plexos: %s Constraint(s) limit what the model may dispatch and the network file " + "enforces none of them; each one the translator can read travels in the extensions " + "sidecar: %s", len(constraints), name_a_few(sorted(constraint.name for constraint in constraints)), ) diff --git a/interop/plugins/steps/plexos_to_pypsa/map_constraints.py b/interop/plugins/steps/plexos_to_pypsa/map_constraints.py index 9d5d260..cab0eba 100644 --- a/interop/plugins/steps/plexos_to_pypsa/map_constraints.py +++ b/interop/plugins/steps/plexos_to_pypsa/map_constraints.py @@ -1,4 +1,4 @@ -"""PLEXOS Constraint -> the translation report, as a sub-step of the composite mapping step.""" +"""PLEXOS Constraint -> extensions sidecar, as a sub-step of the composite mapping step.""" from __future__ import annotations @@ -12,7 +12,7 @@ class PlexosToPypsaMapConstraints(TranslationStep): - """Reports every PLEXOS Constraint, none of which PyPSA has a home for.""" + """Carries each PLEXOS Constraint it can read to the extensions sidecar; PyPSA enforces none.""" name: ClassVar[str] = "plexos_to_pypsa_map_constraints" params_schema: ClassVar[type[BaseModel] | None] = None diff --git a/interop/plugins/steps/pypsa_to_sienna_map_components.py b/interop/plugins/steps/pypsa_to_sienna_map_components.py index 9065a1b..f99c777 100644 --- a/interop/plugins/steps/pypsa_to_sienna_map_components.py +++ b/interop/plugins/steps/pypsa_to_sienna_map_components.py @@ -214,6 +214,7 @@ def run(self, state: State, params: BaseModel | None) -> State: state = self._map_lines(state) state = self._map_links(state) _relay_reserves(state, reader) + _relay_constraints(state, reader) choose_ensemble_samples(state, self._recorder) return state @@ -529,6 +530,19 @@ def _map_links(self, state: State) -> State: return state +def _relay_constraints(state: State, reader: ExtensionReader) -> None: + """Carry each constraint the hop before set aside into this hop's own sidecar. + + Neither PyPSA nor Sienna holds a weighted sum over a named set of components, so a + constraint survives the chain only in the sidecar and travels on as it stands. + """ + append_extensions( + state.destination_extensions, + ExtensionKind.CONSTRAINT, + reader.relay(ExtensionKind.CONSTRAINT), + ) + + def _relay_reserves(state: State, reader: ExtensionReader) -> None: """Carry each reserve the hop before set aside into this hop's own sidecar. diff --git a/tests/features/plexos_to_pypsa/constraints.feature b/tests/features/plexos_to_pypsa/constraints.feature index fbae8bb..d639c24 100644 --- a/tests/features/plexos_to_pypsa/constraints.feature +++ b/tests/features/plexos_to_pypsa/constraints.feature @@ -1,16 +1,22 @@ @slow @fork_unsafe -Feature: PLEXOS Constraint objects are reported rather than silently dropped +Feature: PLEXOS Constraint objects travel in the extensions sidecar A PLEXOS Constraint limits a weighted sum over a named set of objects, and states its right-hand side per day, per hour, per week, per month, per year, or over the whole horizon. PyPSA's GlobalConstraint limits one carrier over the whole horizon and cannot name a set of components, so none of those shapes has a home in the network file. - The translation therefore carries no Constraint, and says so: every right-hand side a - Constraint states is reported against the object stating it, so a reader can see which - limits the solved network is not holding to. + Each Constraint therefore travels in the extensions sidecar instead, and the network's + silence about it is reported: every right-hand side a Constraint states is recorded + against the object stating it, so a reader can see which limits the solved network is not + holding to. A Constraint stating no sense, no sense the translator can read, or no + right-hand side at all, states no inequality to carry, so it is left out and reported as + such. - Scenario: a daily energy limit over named generators is reported, naming what it binds + Scenario: a daily energy limit over named generators reaches the sidecar and the report + The sidecar states the limit in its own vocabulary: the sense as the inequality it holds + in, the right-hand side beside the span it applies over, and each object the sum weights + with the class it belongs to. Given a Plexos model And the model contains region "Grid" And the model contains node "Grid_Node" in region "Grid" @@ -21,16 +27,25 @@ Feature: PLEXOS Constraint objects are reported rather than silently dropped And the model contains constraint "RiverSystem" over: | class | name | coefficient property | coefficient | | Generator | AA1 | Generation Coefficient | 1 | - | Generator | AA2 | Generation Coefficient | 1 | + | Generator | AA2 | Generation Coefficient | 2.5 | And constraint "RiverSystem" states "Sense" of -1 And constraint "RiverSystem" states "RHS Day" of 1.708 + And constraint "RiverSystem" states "Include in LT Plan" of 1 And the model is saved as "inputs/constraint.xml" When I run translate against "inputs/constraint.xml" pipeline "plexos-to-pypsa" sink output "outputs/network.nc" Then the file "outputs/network.nc" exists And the file "decisions.md" contains "`plexos.Constraint.RiverSystem.RHS Day` = 1.708" - And the file "decisions.md" contains "a Constraint holds a weighted sum over the objects it names to its right-hand side, which PyPSA's GlobalConstraint cannot express, so the limit is not carried" - And the file "decisions.md" contains "Sense <= over 2 term(s): 1.0 x Generator AA1 (Generation Coefficient), 1.0 x Generator AA2 (Generation Coefficient)" - And the log contains "plexos: 1 Constraint(s) limit what the model may dispatch, and PyPSA has no home for any of them, so none is enforced: RiverSystem" + And the file "decisions.md" contains "constraint carried to the extensions sidecar; PyPSA's GlobalConstraint cannot hold a weighted sum over the objects a Constraint names, so the network file itself does not limit them" + And the file "decisions.md" contains "Sense <= over 2 term(s): 1.0 x Generator AA1 (Generation Coefficient), 2.5 x Generator AA2 (Generation Coefficient)" + And the log contains "plexos: 1 Constraint(s) limit what the model may dispatch and the network file enforces none of them; each one the translator can read travels in the extensions sidecar: RiverSystem" + And the file "outputs/extensions.json" parses as JSON with "constraint.0.name" set to "RiverSystem" + And the file "outputs/extensions.json" parses as JSON with "constraint.0.sense" set to "<=" + And the file "outputs/extensions.json" parses as JSON with "constraint.0.limits.0.period" set to "day" + And the file "outputs/extensions.json" parses as JSON with "constraint.0.limits.0.value" set to 1.708 + And the file "outputs/extensions.json" parses as JSON with "constraint.0.members.0.name" set to "AA1" + And the file "outputs/extensions.json" parses as JSON with "constraint.0.members.0.member_class" set to "Generator" + And the file "outputs/extensions.json" parses as JSON with "constraint.0.members.1.coefficient" set to 2.5 + And the file "outputs/extensions.json" parses as JSON with "constraint.0.applies_to_expansion_plan" set to true Scenario: every right-hand side a Constraint states is reported, one for each Given a Plexos model @@ -80,6 +95,39 @@ Feature: PLEXOS Constraint objects are reported rather than silently dropped And the model is saved as "inputs/unbounded.xml" When I run translate against "inputs/unbounded.xml" pipeline "plexos-to-pypsa" sink output "outputs/network.nc" Then the file "decisions.md" contains "`plexos.Constraint.Unbounded`" + And the file "decisions.md" contains "this Constraint states no sense, or no right-hand side, so it holds no inequality to carry to the extensions sidecar" + And the file "outputs/extensions.json" does not contain "Unbounded" + + Scenario: a Constraint stating no sense states no inequality, so it is left out + Given a Plexos model + And the model contains region "Grid" + And the model contains node "Grid_Node" in region "Grid" + And the model contains generator "Peaker" with "node=Grid_Node, category=Gas, Max Capacity=100" + And the model contains constraint "Senseless" over: + | class | name | coefficient property | coefficient | + | Generator | Peaker | Generation Coefficient | 1 | + And constraint "Senseless" states "RHS Day" of 400 + And the model is saved as "inputs/senseless.xml" + When I run translate against "inputs/senseless.xml" pipeline "plexos-to-pypsa" sink output "outputs/network.nc" + Then the file "decisions.md" contains "`plexos.Constraint.Senseless.RHS Day` = 400.0" + And the file "decisions.md" contains "this Constraint states no sense, or no right-hand side, so it holds no inequality to carry to the extensions sidecar" + And the file "outputs/extensions.json" does not contain "Senseless" + + Scenario: a Constraint whose Sense the translator cannot read names the code it stated + Given a Plexos model + And the model contains region "Grid" + And the model contains node "Grid_Node" in region "Grid" + And the model contains generator "Peaker" with "node=Grid_Node, category=Gas, Max Capacity=100" + And the model contains constraint "Unreadable" over: + | class | name | coefficient property | coefficient | + | Generator | Peaker | Generation Coefficient | 1 | + And constraint "Unreadable" states "Sense" of 7 + And constraint "Unreadable" states "RHS Day" of 400 + And the model is saved as "inputs/unreadable_sense.xml" + When I run translate against "inputs/unreadable_sense.xml" pipeline "plexos-to-pypsa" sink output "outputs/network.nc" + Then the file "decisions.md" contains "this Constraint states a Sense that is not -1, 0 or 1, so the translator cannot read the inequality it holds in and carries nothing to the extensions sidecar" + And the file "decisions.md" contains "Sense 7.0 over 1 term(s)" + And the file "outputs/extensions.json" does not contain "Unreadable" Scenario: a model with no Constraint objects warns about nothing Given a Plexos model