diff --git a/code-review/ledger.jsonl b/code-review/ledger.jsonl index 5bd120a..fd46498 100644 --- a/code-review/ledger.jsonl +++ b/code-review/ledger.jsonl @@ -252,3 +252,41 @@ {"id": "r17-11", "pr": "9", "repo": "transition-zero/tz-oss-interop", "round": 17, "file": "interop/plugins/shared/plexos_pypsa_translations/constants.py", "line": 20, "severity": "minor", "label": "suggestion", "claim": "The three `EXT_*_FIELD` constants sit in the shared module, but only `_expansion.py` reads them, once each.", "suggested_fix": "", "rule_created": false, "ts": "2026-09-10T15:05:00Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} {"id": "r17-12", "pr": "9", "repo": "transition-zero/tz-oss-interop", "round": 17, "file": "interop/plugins/shared/pypsa_sienna_translations/_shared.py", "line": 3, "severity": "minor", "label": "suggestion", "claim": "The module docstring lists three things this module holds, and the branch added a fourth the list does not name.", "suggested_fix": "", "rule_created": false, "ts": "2026-09-10T15:05:00Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} {"id": "r17-04", "pr": "9", "repo": "transition-zero/tz-oss-interop", "round": 17, "file": "interop/plugins/shared/plexos_pypsa_translations/_generator_derivation.py", "line": 131, "severity": "minor", "label": "suggestion", "claim": "`SourceGenerator.p_nom` builds a `Decision`, keeps the number and drops the sources, and `_p_nom` builds the same `Decision` again.", "suggested_fix": "", "rule_created": false, "ts": "2026-09-10T15:05:00Z", "source": "reviewer", "verdict": "deferred", "reason": "an automated run acts only on a blocking comment or one that removes lines", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-01", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/plexos_pypsa_translations/_lifespan.py", "line": 182, "severity": "major", "label": "suggestion", "claim": "The new `_lifespan` module repeats the band logic that `plexos_dated_properties` already holds.", "suggested_fix": "Move the reading of a set of bands into `plexos_dates.py`, which both modules already import. A code judo move is a restructure that deletes complexity; it does not move the complexity to another place. Three private helpers become one shared pair.\n\nThe code now, in two modules:\n\n```python\n# _lifespan.py\ndef _units_at(bands: list[_UnitsBand], moment: datetime) -> float:\n covering = [band for band in bands if band.dates.covers(moment)]\n return covering[-1].units if covering else _NOT_IN_SERVICE\n\ndef _edges(bands: list[_UnitsBand]) -> list[datetime]:\n moments = {edge for band in bands for edge in (band.dates.date_from, band.dates.ends)}\n return sorted(moment for moment in moments if moment is not None)\n\n# plexos_dated_properties.py\ndef _value_at(ordered: list[DatedRow], outside: DatedRow | None, moment: datetime) -> float | None:\n covering = [dated for dated in ordered if dated.dates.covers(moment)]\n stating = covering[-1] if covering else outside\n```\n\nThe code to use, in `interop/plugins/shared/plexos_dates.py`:\n\n```python\nT = TypeVar(\"T\")\n\ndef latest_covering(bands: Sequence[tuple[DateBand, T]], moment: datetime) -> T | None:\n \"\"\"What the last band covering the moment states, else None.\"\"\"\n covering = [value for band, value in bands if band.covers(moment)]\n return covering[-1] if covering else None\n\ndef band_edges(bands: Iterable[tuple[DateBand, object]]) -> list[datetime]:\n \"\"\"Every moment a band opens or closes, earliest first.\"\"\"\n moments = {edge for band, _ in bands for edge in (band.date_from, band.ends)}\n return sorted(moment for moment in moments if moment is not None)\n```\n\nThen `_units_at`, `_edges`, `_opens`, `_value_at`, `_change_moments` and `_band_order` call the two shared functions, and each caller applies its own zero default.", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "accepted", "reason": "it blocks the merge", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-02", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/plexos_pypsa_translations/_lifespan.py", "line": 107, "severity": "major", "label": "suggestion", "claim": "Two different paths put the same retirement year into the sidecar.", "suggested_fix": "Give `StorageUnitMapping` the `Lifespan` that `RatedObject` already carries, then read both years off it in both paths.\n\nThe code now:\n\n```python\n# _lifespan.py\ndef read_year(decision: Decision) -> int | None:\n return None if decision.value is None else int(decision.value)\n\n# _generators.py:167\n retirement_year=mapping.lifespan.retirement_year,\n\n# _storage_units.py:251\n retirement_year=read_year(mapping.retirement_year),\n```\n\nThe code to use:\n\n```python\n# _storage_shared.py, in StorageUnitMapping\n lifespan: Lifespan = NO_LIFESPAN # in place of retirement_year: Decision\n\n# _storage_units.py, beside the generator path\n retirement_year=mapping.lifespan.retirement_year,\n# and the report derives the Decision where it records it:\n reporter.record(\n mapping.name,\n RETIREMENT_YEAR_COLUMN,\n derive_retirement_year(PlexosClass.BATTERY, mapping.name, mapping.lifespan),\n )\n```\n\nDelete `read_year` with the last caller. Delete the `Lifespan.build_year` property too, because no code reads it.", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "accepted", "reason": "it blocks the merge", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-03", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/sources/plexos_dated_properties.py", "line": 80, "severity": "major", "label": "suggestion", "claim": "The source stages every dated band of every property, but only a dated property holds a schedule.", "suggested_fix": "Keep the rows of a property that states at least one date, and drop the rest.\n\nThe code now:\n\n```python\ndef dated_rows(resolved: list[DatedRow]) -> Rows:\n return [\n {\n **dated.row,\n PlexosDatedPropertyCol.DATE_FROM: dated.dates.date_from,\n PlexosDatedPropertyCol.DATE_TO: dated.dates.date_to,\n }\n for dated in resolved\n ]\n```\n\nThe code to use:\n\n```python\ndef dated_rows(resolved: list[DatedRow]) -> Rows:\n \"\"\"Every row of a property the model dates, beside the dates it applies between.\"\"\"\n by_property: dict[tuple[Any, ...], list[DatedRow]] = {}\n for dated in resolved:\n key = (\n *(dated.row[column] for column in _MEMBERSHIP_NAME_COLUMNS),\n dated.row[PlexosPropertyCol.PROPERTY],\n )\n by_property.setdefault(key, []).append(dated)\n return [\n {\n **dated.row,\n PlexosDatedPropertyCol.DATE_FROM: dated.dates.date_from,\n PlexosDatedPropertyCol.DATE_TO: dated.dates.date_to,\n }\n for group in by_property.values()\n if any(dated.dates != UNDATED for dated in group)\n for dated in group\n ]\n```\n\nThe group holds the undated row beside the dated ones, so `_read_bands` still reads what an object runs before its first band opens.", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "accepted", "reason": "it blocks the merge", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-04", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/pypsa_constants.py", "line": 198, "severity": "minor", "label": "chore", "claim": "`PyPSALineCol.BUILD_YEAR` and `PyPSALinkCol.BUILD_YEAR` are new, but no code reads them.", "suggested_fix": "Delete both constants until a mapping writes the column.\n\n```python\nclass PyPSALineCol:\n ...\n TERRAIN_FACTOR = \"terrain_factor\"\n BUILD_YEAR = \"build_year\" # delete: no schema and no mapping name it\n S_NOM_MIN = \"s_nom_min\"\n```\n\nThe generator and the storage unit keep theirs, because `GENERATORS_DESTINATION_SCHEMA`, `STORAGE_UNITS_DESTINATION_SCHEMA` and `emit_pypsa_network` name them.", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "deferred", "reason": "an automated run acts only on a blocking comment or one that removes lines", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-05", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/core/extensions.py", "line": 264, "severity": "minor", "label": "issue", "claim": "The `sense` field has no default, so `ExtensionLookup.get` raises a ValidationError for a constraint name the sidecar does not hold.", "suggested_fix": "```python\nclass ConstraintExtension(ExtensionRecord):\n ...\n sense: ConstraintSense | None = None\n```\n`_carry` already refuses to build a record without a sense, so no writer emits `None`, and `read(ExtensionKind.CONSTRAINT).get(name)` keeps answering with a record.", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "deferred", "reason": "an automated run acts only on a blocking comment or one that removes lines", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-06", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/plexos_pypsa_translations/_lifespan.py", "line": 152, "severity": "minor", "label": "issue", "claim": "The code discards a retirement that comes before the build, so a unit that stops and restarts gets a wrong `build_year` and no `retirement_year`.", "suggested_fix": "```python\ndef _read_lifespan(bands: list[_UnitsBand]) -> Lifespan:\n changes = _changes(bands)\n retirement = next(\n (one for one in changes if _runs(one.was) and not _runs(one.units)), None\n )\n build = next(\n (\n one\n for one in changes\n if not _runs(one.was)\n and _runs(one.units)\n and (retirement is None or one.at < retirement.at)\n ),\n None,\n )\n return Lifespan(_milestone(build), _milestone(retirement))\n```", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "deferred", "reason": "an automated run acts only on a blocking comment or one that removes lines", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-07", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/pypsa_constants.py", "line": 198, "severity": "nit", "label": "chore", "claim": "The change adds `BUILD_YEAR` to `PyPSALineCol` and to `PyPSALinkCol`, but no module reads either constant.", "suggested_fix": "```python\nclass PyPSALineCol:\n ...\n TERRAIN_FACTOR = \"terrain_factor\"\n S_NOM_MIN = \"s_nom_min\"\n```\nRemove the same line from `PyPSALinkCol` (line 226). Only `PyPSAGeneratorCol.BUILD_YEAR` and `PyPSAStorageUnitCol.BUILD_YEAR` reach a destination schema and the sink.", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "deferred", "reason": "an automated run acts only on a blocking comment or one that removes lines", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-08", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/plexos_pypsa_translations/_lifespan.py", "line": 84, "severity": "minor", "label": "suggestion", "claim": "Four docstrings here only paraphrase the function name. The standard deletes a docstring that repeats the name.", "suggested_fix": "Delete all four. The names already say it, and the derivation strings above say the rest.\n\nThe code now:\n\n```python\ndef read_lifespans(dated: pl.LazyFrame, plexos_class: PlexosClass) -> dict[str, Lifespan]:\n \"\"\"What the dated ``Units`` say about each object of a class that dates them.\"\"\"\n return {name: _read_lifespan(bands) for name, bands in _read_bands(dated, plexos_class).items()}\n\n\ndef derive_build_year(plexos_class: PlexosClass, name: str, lifespan: Lifespan) -> Decision:\n \"\"\"The year the schedule brings the object into service.\"\"\"\n return _derive_year(plexos_class, name, lifespan.build, _BUILD_DERIVATION)\n\n\ndef derive_retirement_year(plexos_class: PlexosClass, name: str, lifespan: Lifespan) -> Decision:\n \"\"\"The year the schedule takes the object out of service, which the sidecar carries.\"\"\"\n return _derive_year(plexos_class, name, lifespan.retirement, _RETIREMENT_DERIVATION)\n\n\ndef _derive_year(\n plexos_class: PlexosClass, name: str, milestone: Milestone | None, derivation: str\n) -> Decision:\n if milestone is None:\n return Decision.unreported(None)\n source = SourceValue(plexos_class, name, PlexosProperty.UNITS, milestone.units)\n return Decision.derived(milestone.year, [source], derivation)\n\n\ndef read_year(decision: Decision) -> int | None:\n \"\"\"The year a lifespan decision states, for the sidecar field that carries it.\"\"\"\n return None if decision.value is None else int(decision.value)\n```\n\nThe code to use:\n\n```python\ndef read_lifespans(dated: pl.LazyFrame, plexos_class: PlexosClass) -> dict[str, Lifespan]:\n return {name: _read_lifespan(bands) for name, bands in _read_bands(dated, plexos_class).items()}\n\n\ndef derive_build_year(plexos_class: PlexosClass, name: str, lifespan: Lifespan) -> Decision:\n return _derive_year(plexos_class, name, lifespan.build, _BUILD_DERIVATION)\n\n\ndef derive_retirement_year(plexos_class: PlexosClass, name: str, lifespan: Lifespan) -> Decision:\n return _derive_year(plexos_class, name, lifespan.retirement, _RETIREMENT_DERIVATION)\n\n\ndef _derive_year(\n plexos_class: PlexosClass, name: str, milestone: Milestone | None, derivation: str\n) -> Decision:\n if milestone is None:\n return Decision.unreported(None)\n source = SourceValue(plexos_class, name, PlexosProperty.UNITS, milestone.units)\n return Decision.derived(milestone.year, [source], derivation)\n\n\ndef read_year(decision: Decision) -> int | None:\n return None if decision.value is None else int(decision.value)\n```", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-09", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/plexos_dates.py", "line": 3, "severity": "minor", "label": "suggestion", "claim": "This paragraph argues why the module exists. The standard deletes a reviewer justification.", "suggested_fix": "Delete the paragraph and keep the summary line. Its first sentence also stands word for word in the ``plexos_dated_properties`` module docstring.\n\nThe code now:\n\n```python\n\"\"\"When a dated PLEXOS value applies.\n\nPLEXOS stamps a ``t_data`` row with the dates it applies between. The source narrows those\nbands to the window being translated, and a mapping reading a schedule out of them -- the\nyear a unit arrives, the year it goes -- reads the same bands as the model states them, so\nboth sides share one reading of what a band covers.\n\"\"\"\n```\n\nThe code to use:\n\n```python\n\"\"\"When a dated PLEXOS value applies.\"\"\"\n```", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-10", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/plexos_pypsa_translations/_constraints.py", "line": 215, "severity": "minor", "label": "suggestion", "claim": "This paragraph narrates the test on the next line. The standard deletes a comment that explains the code below it.", "suggested_fix": "Delete the second paragraph and keep the summary line, which states the contract of the return value.\n\nThe code now:\n\n```python\ndef _carry(constraint: _Constraint) -> ConstraintExtension | None:\n \"\"\"The sidecar record, or None where the constraint states too little to hold anything to.\n\n A limit needs both a sense and a right-hand side: without either there is no inequality\n to state, whatever the objects it names.\n \"\"\"\n if constraint.sense is None or not constraint.right_hand_sides:\n return None\n```\n\nThe code to use:\n\n```python\ndef _carry(constraint: _Constraint) -> ConstraintExtension | None:\n \"\"\"The sidecar record, or None where the constraint states too little to hold anything to.\"\"\"\n if constraint.sense is None or not constraint.right_hand_sides:\n return None\n```", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-11", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/core/extensions.py", "line": 258, "severity": "minor", "label": "suggestion", "claim": "This paragraph repeats the ExtensionKind.CONSTRAINT comment in the same file. The standard deletes a duplicate.", "suggested_fix": "Delete the paragraph and keep the summary line. The same argument now stands in four new places: this docstring, the ``ExtensionKind.CONSTRAINT`` comment at line 50, the ``_constraints`` module docstring and ``_relay_constraints``.\n\nThe code now:\n\n```python\nclass ConstraintExtension(ExtensionRecord):\n \"\"\"A weighted sum over named objects, held to one or more right-hand sides.\n\n PLEXOS has the concept and PyPSA has none: a GlobalConstraint limits one carrier over\n the whole horizon and cannot name a set of components. A constraint therefore travels\n here rather than in the network, so a later hop into a framework that can express it\n still has the limit.\n \"\"\"\n```\n\nThe code to use:\n\n```python\nclass ConstraintExtension(ExtensionRecord):\n \"\"\"A weighted sum over named objects, held to one or more right-hand sides.\"\"\"\n```", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-12", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/plexos_constants.py", "line": 105, "severity": "minor", "label": "nitpick", "claim": "This sentence copies the DateBand.ends docstring in plexos_dates.py. The standard deletes a duplicate that will drift.", "suggested_fix": "Delete the sentence. ``DateBand.ends`` states the same rule where the code acts on it.\n\nThe code now:\n\n```python\nclass PlexosDatedPropertyCol(PlexosPropertyCol):\n \"\"\"Columns of the resolved ``dated_properties`` table.\n\n The ``properties`` columns, plus the dates the value on the row applies between. Every\n resolved row is here, whether or not the window being translated covers its band, so a\n mapping reading a schedule sees the years the model states rather than one value.\n ``date_to`` names a whole day, so the band runs to the end of it.\n \"\"\"\n```\n\nThe code to use:\n\n```python\nclass PlexosDatedPropertyCol(PlexosPropertyCol):\n \"\"\"Columns of the resolved ``dated_properties`` table.\n\n The ``properties`` columns, plus the dates the value on the row applies between. Every\n resolved row is here, whether or not the window being translated covers its band, so a\n mapping reading a schedule sees the years the model states rather than one value.\n \"\"\"\n```", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-13", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/plexos_pypsa_translations/constants.py", "line": 27, "severity": "nit", "label": "nitpick", "claim": "This docstring is the third identical copy in eight lines. The standard deletes a duplicate.", "suggested_fix": "Give the three sidecar-key constants one comment above the group and drop the repeated docstrings.\n\nThe code now:\n\n```python\nEXT_UNIT_SIZE_FIELD: str = \"extensions.unit_size_mw\"\n\"\"\"Names that sidecar key in the audit trail; the network file itself has no such column.\"\"\"\n\nEXT_TECHNICAL_LIFE_FIELD: str = \"extensions.technical_life_years\"\n\"\"\"Names that sidecar key in the audit trail; the network file itself has no such column.\"\"\"\n\nEXT_RETIREMENT_YEAR_FIELD: str = \"extensions.retirement_year\"\n\"\"\"Names that sidecar key in the audit trail; the network file itself has no such column.\"\"\"\n```\n\nThe code to use:\n\n```python\n# Each names a sidecar key in the audit trail; the network file itself has no such column.\nEXT_UNIT_SIZE_FIELD: str = \"extensions.unit_size_mw\"\nEXT_TECHNICAL_LIFE_FIELD: str = \"extensions.technical_life_years\"\nEXT_RETIREMENT_YEAR_FIELD: str = \"extensions.retirement_year\"\n```", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-14", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/plexos_pypsa_translations/_lifespan.py", "line": 56, "severity": "minor", "label": "chore", "claim": "The property `Lifespan.build_year` has no caller.", "suggested_fix": "Delete the `build_year` property from `Lifespan`. The build year reaches the network through `derive_build_year`, which reads `lifespan.build` itself. Net: -4 lines.", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-15", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/pypsa_constants.py", "line": 198, "severity": "nit", "label": "chore", "claim": "The branch adds `BUILD_YEAR` to `PyPSALineCol` and `PyPSALinkCol`, and no code reads either name.", "suggested_fix": "Delete `BUILD_YEAR` from `PyPSALineCol` (line 198) and from `PyPSALinkCol` (line 226), and add each one back with the mapping that first writes a build year onto a line or a link. Net: -2 lines.", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-16", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/plexos_pypsa_translations/_lifespan.py", "line": 107, "severity": "nit", "label": "suggestion", "claim": "The one-line helper `read_year` has one caller, and the other sidecar field takes the same year by a second route.", "suggested_fix": "Use one route for both. In `_generators._carry_to_extensions`, hold the `derive_retirement_year` decision it already builds for the report, give it to `read_year` for `GeneratorExtension.retirement_year`, and delete the `Lifespan.retirement_year` property. Net: -4 lines.", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r1-17", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 1, "file": "interop/plugins/shared/plexos_pypsa_translations/_constraints.py", "line": 247, "severity": "minor", "label": "issue", "claim": "`_NOT_CARRIED_NOTE` states that PyPSA cannot express the limit, but the code now applies it to a Constraint that is left out because the source states no sense or no right-hand side.", "suggested_fix": "Add a note that names the missing part, and select it where `_carry` returns None because `constraint.sense` is None or `constraint.right_hand_sides` is empty. Net: +6 lines.", "rule_created": false, "ts": "2026-09-08T22:05:22Z", "source": "reviewer", "verdict": "deferred", "reason": "an automated run acts only on a blocking comment or one that removes lines", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r2-01", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 2, "file": "interop/plugins/shared/plexos_pypsa_translations/_lifespan.py", "line": 85, "severity": "major", "label": "suggestion", "claim": "The build year and the retirement year are two ends of one concept, but four call sites derive them one end at a time.", "suggested_fix": "Bundle the two ends as `_expansion.py` bundles the expansion fields, and let each mapping hold the bundle.\n\nThe code now:\n\n```python\n# _generator_decisions.py\nbuild_year=derive_build_year(PlexosClass.GENERATOR, mapping.name, mapping.lifespan),\n# _generators.py\nretirements = {\n mapping.name: derive_retirement_year(PlexosClass.GENERATOR, mapping.name, mapping.lifespan)\n for mapping in mappings\n}\n# _batteries.py\nbuild_year=derive_build_year(PlexosClass.BATTERY, rated.name, rated.lifespan),\nretirement_year=derive_retirement_year(PlexosClass.BATTERY, rated.name, rated.lifespan),\n```\n\nThe code to use:\n\n```python\n# _lifespan.py\n@dataclass(frozen=True)\nclass LifespanDecisions:\n build_year: Decision = maps_to(PyPSAGeneratorCol.BUILD_YEAR)\n # The sidecar carries the retirement year, since PyPSA has no column for it.\n retirement_year: Decision = NOTHING_TO_REPORT\n\n\ndef derive_lifespan(plexos_class: PlexosClass, name: str, lifespan: Lifespan) -> LifespanDecisions:\n return LifespanDecisions(\n build_year=_derive_year(plexos_class, name, lifespan.build, _BUILD_DERIVATION),\n retirement_year=_derive_year(plexos_class, name, lifespan.retirement, _RETIREMENT_DERIVATION),\n )\n\n\ndef record_lifespan(name: str, decisions: LifespanDecisions, reporter: ComponentReporter) -> None:\n reporter.record(name, RETIREMENT_YEAR_COLUMN, decisions.retirement_year)\n\n# each mapping then declares one field\nlifespan: LifespanDecisions = holds()\n```", "rule_created": false, "ts": "2026-09-08T22:30:23Z", "source": "reviewer", "verdict": "accepted", "reason": "it blocks the merge", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r2-02", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 2, "file": "interop/plugins/sources/plexos_dated_properties.py", "line": 132, "severity": "major", "label": "issue", "claim": "The `outside` fallback can never run, because an undated band covers every moment and `latest_covering` therefore always finds it.", "suggested_fix": "Delete `_stated_for_no_date` and the `outside` parameter.\n\nThe code now:\n\n```python\ndef _stated_for_no_date(ordered: list[DatedRow]) -> dict[str, Any] | None:\n return next((dated.row for dated in ordered if dated.dates == UNDATED), None)\n\n\ndef _value_at(\n ordered: list[DatedRow], outside: dict[str, Any] | None, moment: datetime\n) -> float | None:\n stating = latest_covering(ordered, moment)\n if stating is None:\n stating = outside\n if stating is None:\n return _NOT_IN_EFFECT\n value: float | None = stating[PlexosPropertyCol.VALUE]\n return value\n```\n\nThe code to use:\n\n```python\ndef _value_at(ordered: list[DatedRow], moment: datetime) -> float | None:\n \"\"\"The latest band covering the moment, else none.\n\n A property stated only for a period is not in effect outside one, and a property with\n no value in effect is a property the model is not applying: it reads as zero.\n \"\"\"\n stating = latest_covering(ordered, moment)\n if stating is None:\n return _NOT_IN_EFFECT\n value: float | None = stating[PlexosPropertyCol.VALUE]\n return value\n```\n\n`_steps_within` then drops its `outside` local and calls `_value_at(ordered, moment)`.", "rule_created": false, "ts": "2026-09-08T22:30:23Z", "source": "reviewer", "verdict": "accepted", "reason": "it blocks the merge", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r2-03", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 2, "file": "interop/plugins/shared/plexos_pypsa_translations/_constraints.py", "line": 110, "severity": "minor", "label": "suggestion", "claim": "The `_Outcome` dataclass and the optional return of `_carry` both state one predicate, which the `_Constraint` itself can answer.", "suggested_fix": "Ask the constraint whether it holds an inequality, and let `_carry` return a record.\n\nThe code now:\n\n```python\n@dataclass(frozen=True)\nclass _Outcome:\n constraint: _Constraint\n record: ConstraintExtension | None\n\n\ndef map_constraints(state: State, recorder: ScopedRecorder) -> None:\n constraints = _read_constraints(state)\n if not constraints:\n return\n outcomes = [_Outcome(constraint, _carry(constraint)) for constraint in constraints]\n reporter = SourceReporter(recorder)\n for outcome in outcomes:\n _record(reporter, outcome)\n append_extensions(\n state.destination_extensions,\n ExtensionKind.CONSTRAINT,\n [outcome.record for outcome in outcomes if outcome.record is not None],\n )\n _warn(constraints)\n```\n\nThe code to use:\n\n```python\n# on _Constraint\n@property\ndef holds_an_inequality(self) -> bool:\n return self.sense is not None and bool(self.right_hand_sides)\n\n\ndef map_constraints(state: State, recorder: ScopedRecorder) -> None:\n constraints = _read_constraints(state)\n if not constraints:\n return\n reporter = SourceReporter(recorder)\n for constraint in constraints:\n _record(reporter, constraint)\n append_extensions(\n state.destination_extensions,\n ExtensionKind.CONSTRAINT,\n [_carry(one) for one in constraints if one.holds_an_inequality],\n )\n _warn(constraints)\n```\n\n`_record` then reads `constraint.holds_an_inequality` to choose between `_CARRIED_NOTE` and `_NOT_CARRIED_NOTE`.", "rule_created": false, "ts": "2026-09-08T22:30:23Z", "source": "reviewer", "verdict": "deferred", "reason": "an automated run acts only on a blocking comment or one that removes lines", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r2-04", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 2, "file": "interop/plugins/shared/plexos_pypsa_translations/_lifespan.py", "line": 141, "severity": "minor", "label": "question", "claim": "The end of a dated Units band reads as a retirement, so a model that states no zero still gets a retirement_year.", "suggested_fix": "```python\ndef _read_lifespan(bands: list[_UnitsBand]) -> Lifespan:\n changes = _changes(bands)\n build = next((one for one in changes if not _runs(one.was) and _runs(one.units)), None)\n retirement = next(\n (\n one\n for one in changes\n if _runs(one.was)\n and not _runs(one.units)\n # A band must state the zero; a schedule that simply stops states no retirement.\n and latest_covering(bands, one.at) is not None\n and (build is None or one.at > build.at)\n ),\n None,\n )\n return Lifespan(_milestone(build), _milestone(retirement))\n```", "rule_created": false, "ts": "2026-09-08T22:30:23Z", "source": "reviewer", "verdict": "deferred", "reason": "an automated run acts only on a blocking comment or one that removes lines", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r2-05", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 2, "file": "interop/plugins/shared/plexos_pypsa_translations/_constraints.py", "line": 204, "severity": "minor", "label": "issue", "claim": "_read_sense turns a Sense code it cannot map into None, so the report tells the user the Constraint states no sense.", "suggested_fix": "```python\n_UNREADABLE_SENSE_NOTE = (\n \"this Constraint states a Sense that is not -1, 0 or 1, so the translator cannot read \"\n \"the inequality it holds in and carries nothing to the extensions sidecar\"\n)\n\n\ndef _not_carried_note(constraint: _Constraint) -> str:\n if constraint.sense is None and constraint.stated_sense is not None:\n return _UNREADABLE_SENSE_NOTE\n return _NOT_CARRIED_NOTE\n```\nKeep the stated code on `_Constraint` (`stated_sense=stated.get(PlexosProperty.SENSE)`) and pick the note in `_record`.", "rule_created": false, "ts": "2026-09-08T22:30:23Z", "source": "reviewer", "verdict": "deferred", "reason": "an automated run acts only on a blocking comment or one that removes lines", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r2-06", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 2, "file": "interop/core/extensions.py", "line": 98, "severity": "minor", "label": "suggestion", "claim": "This paragraph repeats the PLEXOS property that each enum member below already names in its own comment.", "suggested_fix": "Delete the second paragraph. The per-member comments carry the same mapping.\n\nThe code now:\n\n```python\nclass ConstraintPeriod(StrEnum):\n \"\"\"The span one right-hand side applies over.\n\n PLEXOS states a limit per hour, day, week, month or year, or over the whole horizon,\n and spells each as a right-hand side property of its own.\n \"\"\"\n\n HORIZON = \"horizon\" # PLEXOS RHS\n HOUR = \"hour\" # PLEXOS RHS Hour\n DAY = \"day\" # PLEXOS RHS Day\n WEEK = \"week\" # PLEXOS RHS Week\n MONTH = \"month\" # PLEXOS RHS Month\n YEAR = \"year\" # PLEXOS RHS Year\n```\n\nThe code to use:\n\n```python\nclass ConstraintPeriod(StrEnum):\n \"\"\"The span one right-hand side applies over.\"\"\"\n\n HORIZON = \"horizon\" # PLEXOS RHS\n HOUR = \"hour\" # PLEXOS RHS Hour\n DAY = \"day\" # PLEXOS RHS Day\n WEEK = \"week\" # PLEXOS RHS Week\n MONTH = \"month\" # PLEXOS RHS Month\n YEAR = \"year\" # PLEXOS RHS Year\n```", "rule_created": false, "ts": "2026-09-08T22:30:23Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r2-07", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 2, "file": "interop/plugins/shared/pypsa_constants.py", "line": 492, "severity": "nit", "label": "nitpick", "claim": "This comment paraphrases the column name build_year, and the storage unit schema repeats it.", "suggested_fix": "Delete both comments. Neighbouring comments in these dicts earn their place because they state what the sink does with a null column; these two only restate the name.\n\nThe code now:\n\n```python\n PyPSAGeneratorCol.FOM_COST: pl.Float64,\n # The year the source brings the generator into service, null where it states none.\n PyPSAGeneratorCol.BUILD_YEAR: pl.Int64,\n}\n```\n\nand at line 531:\n\n```python\n PyPSAStorageUnitCol.FOM_COST: pl.Float64,\n # The year the source brings the storage unit into service, null where it states none.\n PyPSAStorageUnitCol.BUILD_YEAR: pl.Int64,\n}\n```\n\nThe code to use:\n\n```python\n PyPSAGeneratorCol.FOM_COST: pl.Float64,\n PyPSAGeneratorCol.BUILD_YEAR: pl.Int64,\n}\n```\n\nand:\n\n```python\n PyPSAStorageUnitCol.FOM_COST: pl.Float64,\n PyPSAStorageUnitCol.BUILD_YEAR: pl.Int64,\n}\n```", "rule_created": false, "ts": "2026-09-08T22:30:23Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r2-08", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 2, "file": "interop/plugins/shared/plexos_pypsa_translations/_lifespan.py", "line": 136, "severity": "nit", "label": "nitpick", "claim": "This docstring states a circular sentence and does not say what the function returns.", "suggested_fix": "Delete it. The name and the one-line body already say that the function looks for a dated band.\n\nThe code now:\n\n```python\ndef _states_a_date(bands: list[_UnitsBand]) -> bool:\n \"\"\"An object whose ``Units`` are one value for all time keeps that value for all time.\"\"\"\n return any(band.dates != UNDATED for band in bands)\n```\n\nThe code to use:\n\n```python\ndef _states_a_date(bands: list[_UnitsBand]) -> bool:\n return any(band.dates != UNDATED for band in bands)\n```", "rule_created": false, "ts": "2026-09-08T22:30:23Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r2-09", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 2, "file": "interop/plugins/shared/plexos_dates.py", "line": 33, "severity": "nit", "label": "nitpick", "claim": "This docstring paraphrases the function name and the return type.", "suggested_fix": "Delete it.\n\nThe code now:\n\n```python\ndef latest_covering(bands: Sequence[tuple[DateBand, T]], moment: datetime) -> T | None:\n \"\"\"What the last band covering the moment states, else None.\"\"\"\n covering = [value for band, value in bands if band.covers(moment)]\n return covering[-1] if covering else None\n```\n\nThe code to use:\n\n```python\ndef latest_covering(bands: Sequence[tuple[DateBand, T]], moment: datetime) -> T | None:\n covering = [value for band, value in bands if band.covers(moment)]\n return covering[-1] if covering else None\n```", "rule_created": false, "ts": "2026-09-08T22:30:23Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r2-10", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 2, "file": "interop/plugins/shared/plexos_pypsa_translations/_constraints.py", "line": 74, "severity": "nit", "label": "nitpick", "claim": "This comment paraphrases the dictionary below it, which pairs each right-hand side property with a span.", "suggested_fix": "Delete it.\n\nThe code now:\n\n```python\n# The right-hand side properties, each with the span it holds the sum over.\n_PERIODS: dict[str, ConstraintPeriod] = {\n PlexosProperty.RHS: ConstraintPeriod.HORIZON,\n PlexosProperty.RHS_HOUR: ConstraintPeriod.HOUR,\n```\n\nThe code to use:\n\n```python\n_PERIODS: dict[str, ConstraintPeriod] = {\n PlexosProperty.RHS: ConstraintPeriod.HORIZON,\n PlexosProperty.RHS_HOUR: ConstraintPeriod.HOUR,\n```", "rule_created": false, "ts": "2026-09-08T22:30:23Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r2-11", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 2, "file": "interop/plugins/steps/plexos_to_pypsa/map_constraints.py", "line": 15, "severity": "major", "label": "issue", "claim": "The class docstring says the step carries every PLEXOS Constraint to the sidecar, but a Constraint that states no sense, or no right-hand side, is left out.", "suggested_fix": "Change the docstring to \"Carries each PLEXOS Constraint the translator can read to the extensions sidecar; PyPSA enforces none.\", which is what the log warning already says. Net: 0 lines.", "rule_created": false, "ts": "2026-09-08T22:30:23Z", "source": "reviewer", "verdict": "accepted", "reason": "it blocks the merge", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r2-12", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 2, "file": "tests/features/plexos_to_pypsa/constraints.feature", "line": 103, "severity": "minor", "label": "suggestion", "claim": "The new scenario \"a readable Constraint reaches the sidecar...\" repeats the whole model of the first scenario, down to the constraint name, the sense and the right-hand side.", "suggested_fix": "Delete the new scenario, add `And constraint \"RiverSystem\" states \"Include in LT Plan\" of 1` and the AA2 coefficient of 2.5 to the first scenario, and move the eight `extensions.json` assertions onto it. No assertion is lost, and one full translate run goes with it. Net: -20 lines.", "rule_created": false, "ts": "2026-09-08T22:30:23Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r3-01", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 3, "file": "interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py", "line": 200, "severity": "major", "label": "suggestion", "claim": "The class `StorageLookups` states five readings twice, once for a Battery and once for a Generator, and this change adds the fifth pair.", "suggested_fix": "The code now:\n\n```python\n lifespan_by_battery: dict[str, Lifespan]\n lifespan_by_generator: dict[str, Lifespan]\n\n def battery(self, name: str) -> StagedObject:\n return StagedObject(\n name=name,\n properties=self.battery_properties.get(name, {}),\n stated_units=self.battery_units.get(name, {}),\n node=self.node_by_battery.get(name),\n file_backed=self.file_backed_by_battery.get(name, []),\n lifespan=self.lifespan_by_battery.get(name, NO_LIFESPAN),\n )\n```\n\n`generator` holds the same body, over the five `_by_generator` fields.\n\nThe code to use:\n\n```python\n@dataclass(frozen=True)\nclass _ClassLookups:\n \"\"\"What one PLEXOS class states about each of its objects.\"\"\"\n\n properties: ObjectProperties\n stated_units: ObjectUnits\n nodes: dict[str, str]\n file_backed: dict[str, list[str]]\n lifespans: dict[str, Lifespan]\n\n\n@dataclass(frozen=True)\nclass StorageLookups:\n by_class: dict[PlexosClass, _ClassLookups]\n\n def staged(self, plexos_class: PlexosClass, name: str) -> StagedObject:\n one = self.by_class[plexos_class]\n return StagedObject(\n name=name,\n properties=one.properties.get(name, {}),\n stated_units=one.stated_units.get(name, {}),\n node=one.nodes.get(name),\n file_backed=one.file_backed.get(name, []),\n lifespan=one.lifespans.get(name, NO_LIFESPAN),\n )\n```\n\nThe two call sites become `lookups.staged(PlexosClass.BATTERY, name)` in `_batteries.py`\nand `lookups.staged(PlexosClass.GENERATOR, name)` in `_storage_hydro.py`.", "rule_created": false, "ts": "2026-09-08T22:56:48Z", "source": "reviewer", "verdict": "accepted", "reason": "it blocks the merge", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r3-02", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 3, "file": "interop/plugins/shared/plexos_pypsa_translations/_constraints.py", "line": 116, "severity": "minor", "label": "suggestion", "claim": "The class `_Outcome` and the optional return of `_carry` both state one fact, which the `_Constraint` can state itself.", "suggested_fix": "The code now:\n\n```python\n@dataclass(frozen=True)\nclass _Outcome:\n \"\"\"One Constraint and the record it travels as, which is None where it states too little.\"\"\"\n\n constraint: _Constraint\n record: ConstraintExtension | None\n\n\ndef map_constraints(state: State, recorder: ScopedRecorder) -> None:\n constraints = _read_constraints(state)\n if not constraints:\n return\n outcomes = [_Outcome(constraint, _carry(constraint)) for constraint in constraints]\n reporter = SourceReporter(recorder)\n for outcome in outcomes:\n _record(reporter, outcome)\n append_extensions(\n state.destination_extensions,\n ExtensionKind.CONSTRAINT,\n [outcome.record for outcome in outcomes if outcome.record is not None],\n )\n _warn(constraints)\n```\n\nThe code to use:\n\n```python\n@dataclass(frozen=True)\nclass _Constraint:\n # the fields stay as they are\n\n @property\n def is_carried(self) -> bool:\n return self.sense is not None and bool(self.right_hand_sides)\n\n\ndef map_constraints(state: State, recorder: ScopedRecorder) -> None:\n constraints = _read_constraints(state)\n if not constraints:\n return\n reporter = SourceReporter(recorder)\n for constraint in constraints:\n _record(reporter, constraint)\n append_extensions(\n state.destination_extensions,\n ExtensionKind.CONSTRAINT,\n [_carry(one) for one in constraints if one.is_carried],\n )\n _warn(constraints)\n```\n\n`_carry` then returns a `ConstraintExtension`, and `_record` takes a `_Constraint` and\nreads `constraint.is_carried`.", "rule_created": false, "ts": "2026-09-08T22:56:48Z", "source": "reviewer", "verdict": "deferred", "reason": "an automated run acts only on a blocking comment or one that removes lines", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r3-03", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 3, "file": "docs/translation_mappings/translation-from-plexos-to-pypsa.md", "line": 777, "severity": "minor", "label": "issue", "claim": "The document says the translator leaves out a component that runs no units, but the translator keeps a candidate with zero units.", "suggested_fix": "```markdown\n| `Units` | ... A component that runs no units in the year translated is still left out, since it can dispatch nothing that year, unless it is a [candidate](#what-a-candidate-is), which the translator writes as extendable. |\n```", "rule_created": false, "ts": "2026-09-08T22:56:48Z", "source": "reviewer", "verdict": "deferred", "reason": "an automated run acts only on a blocking comment or one that removes lines", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r3-04", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 3, "file": "interop/plugins/sources/plexos_dated_properties.py", "line": 132, "severity": "minor", "label": "suggestion", "claim": "This first line paraphrases the name, and it is wrong: no covering band gives zero, not none.", "suggested_fix": "Delete the first line. The paragraph below it already gives the reader the rule, and gives it correctly.\n\nThe code now:\n\n```python\ndef _value_at(ordered: list[DatedRow], moment: datetime) -> float | None:\n \"\"\"The latest band covering the moment, else none.\n\n A property stated only for a period is not in effect outside one, and a property with\n no value in effect is a property the model is not applying: it reads as zero.\n \"\"\"\n stating = latest_covering(ordered, moment)\n```\n\nThe code to use:\n\n```python\ndef _value_at(ordered: list[DatedRow], moment: datetime) -> float | None:\n \"\"\"A property stated only for a period is not in effect outside one, and a property\n with no value in effect is a property the model is not applying: it reads as zero.\n \"\"\"\n stating = latest_covering(ordered, moment)\n```", "rule_created": false, "ts": "2026-09-08T22:56:48Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r3-05", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 3, "file": "interop/plugins/shared/plexos_pypsa_translations/_lifespan.py", "line": 79, "severity": "nit", "label": "suggestion", "claim": "This docstring paraphrases the two field names and adds nothing to them.", "suggested_fix": "Delete it.\n\nThe code now:\n\n```python\nclass _UnitsBand(NamedTuple):\n \"\"\"How many units one dated ``Units`` row runs, and the dates it runs them between.\"\"\"\n\n dates: DateBand\n units: float\n```\n\nThe code to use:\n\n```python\nclass _UnitsBand(NamedTuple):\n dates: DateBand\n units: float\n```", "rule_created": false, "ts": "2026-09-08T22:56:48Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r3-06", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 3, "file": "interop/plugins/shared/plexos_pypsa_translations/_constraints.py", "line": 117, "severity": "minor", "label": "suggestion", "claim": "This docstring repeats what the _carry docstring says, and it restates the None in the type.", "suggested_fix": "Delete it.\n\nThe code now:\n\n```python\n@dataclass(frozen=True)\nclass _Outcome:\n \"\"\"One Constraint and the record it travels as, which is None where it states too little.\"\"\"\n\n constraint: _Constraint\n record: ConstraintExtension | None\n```\n\nThe code to use:\n\n```python\n@dataclass(frozen=True)\nclass _Outcome:\n constraint: _Constraint\n record: ConstraintExtension | None\n```", "rule_created": false, "ts": "2026-09-08T22:56:48Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r3-07", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 3, "file": "docs/translation_mappings/translation-from-plexos-to-pypsa.md", "line": 777, "severity": "major", "label": "issue", "claim": "The new sentence says a component that runs no units in the translated year is always left out, but a candidate that runs no units is written with a `build_year`.", "suggested_fix": "Delete the sentence \"A component running no units in the year translated is still left out, since it can dispatch nothing that year.\" from the `Units` row. The skipped-component table at line 289 already states the real rule, which is that the generator goes only when it has no `Units` at any time in the horizon **and** no `Max Units Built`. Net: 0 lines.", "rule_created": false, "ts": "2026-09-08T22:56:48Z", "source": "reviewer", "verdict": "accepted", "reason": "it blocks the merge", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r3-08", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 3, "file": "interop/plugins/shared/plexos_pypsa_translations/_lifespan.py", "line": 152, "severity": "minor", "label": "suggestion", "claim": "The guard `_states_a_date` cannot change any result, because a set of bands that are all undated already gives no milestone.", "suggested_fix": "Delete `_states_a_date`, make the return of `_read_bands` `return {name: sorted(rows, key=opens_at) for name, rows in bands.items()}`, and drop the now unused `UNDATED` import. Net: -7 lines.", "rule_created": false, "ts": "2026-09-08T22:56:48Z", "source": "reviewer", "verdict": "accepted", "reason": "it takes lines away", "category": null, "promote_now": false, "suppressed_by": null} +{"id": "r3-09", "pr": "10", "repo": "transition-zero/tz-oss-interop", "round": 3, "file": "interop/plugins/sources/plexos_dated_properties.py", "line": 86, "severity": "nit", "label": "nitpick", "claim": "The new function `dated_rows` has the same name as a local variable in `apply_window` in the same module.", "suggested_fix": "Rename the loop variable at lines 78-79 of `apply_window` to `rows`, so the module has one meaning for `dated_rows`. Net: 0 lines.", "rule_created": false, "ts": "2026-09-08T22:56:48Z", "source": "reviewer", "verdict": "deferred", "reason": "an automated run acts only on a blocking comment or one that removes lines", "category": null, "promote_now": false, "suppressed_by": null} diff --git a/docs/translation_mappings/translation-from-plexos-to-pypsa.md b/docs/translation_mappings/translation-from-plexos-to-pypsa.md index 6e86075..6fe8cfc 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 @@ -277,6 +278,7 @@ is `committable` when it is thermal, or when its `p_min_pu` is more than `0`. | `discount_rate` | | `WACC`, for a candidate whose build the model prices | `derived` | | `lifetime` | yr | `Economic Life`, for a candidate whose build the model prices | `direct` | | `extensions.fom_charge_per_mw_year` | $/MW/yr | `FO&M Charge`, for a candidate whose build the model prices | `direct` | +| `build_year` | yr | The first year the dated [`Units`](#which-entry-applies-when) rise above zero. The field is absent where the model dates no `Units`, and PyPSA reads `0`. | `derived` | **The translator does not translate seven cases.** It records each one as a skipped component: @@ -361,6 +363,7 @@ has more than one fuel uses its primary fuel. | `cyclic_state_of_charge` | | `True` if `End Effects Method` is `RECYCLE`, or if the model gives no `Initial SoC` | `derived` | | `marginal_cost` | $/MWh | `0.0` | `default` | | `p_nom_extendable`, `p_nom_min`, `p_nom_max`, `overnight_cost`, `discount_rate`, `lifetime` | | Refer to [What a candidate is](#what-a-candidate-is) | `derived` / `default` | +| `build_year` | yr | The first year the dated [`Units`](#which-entry-applies-when) rise above zero. The field is absent where the model dates no `Units`, and PyPSA reads `0`. | `derived` | The energy one unit of a battery holds is its `Capacity`. If the model gives a duration in place of a capacity, that energy is `Duration × Max Power`. `max_hours` is that energy @@ -776,7 +779,7 @@ Two properties use dated entries as a schedule. They do not use them as correcti | Property | Meaning | | --- | --- | -| `Units` | A capacity that starts, retires or partly derates. A static value above zero with a later entry of zero is a **retirement**. A static zero, or no value, with a later entry above zero is a **new build**. | +| `Units` | A capacity that starts, retires or partly derates. A static value above zero with a later entry of zero is a **retirement**. A static zero, or no value, with a later entry above zero is a **new build**. The translator reads both years off the entries as the model dates them, whatever year it translates: the first year the value rises above zero is the `build_year` of the component it writes, and the last year in which it falls back to zero is the `retirement_year` in the extensions sidecar. An object that already runs before its first dated entry states no `build_year`, and one that runs again after an entry of zero states no `retirement_year`, because that entry is a mothball rather than a retirement. A generator that runs no units in the year being translated and states no `Max Units Built` is left out as retired, so no `build_year` reaches the network for it. | | `Max Capacity` | A capacity expansion schedule. The translator applies the entry that is in force at the snapshot. Where it needs one value, it uses the entry that is in force at the start of the model. | ### Timeslice patterns @@ -1001,7 +1004,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. | @@ -1010,7 +1013,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`, @@ -1020,14 +1023,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/docs/translation_mappings/translation-from-plexos-to-sienna.md b/docs/translation_mappings/translation-from-plexos-to-sienna.md index 34252de..8afdb2f 100644 --- a/docs/translation_mappings/translation-from-plexos-to-sienna.md +++ b/docs/translation_mappings/translation-from-plexos-to-sienna.md @@ -35,7 +35,7 @@ property of the PLEXOS to Sienna mapping. | [`Market`](#market--thermalstandard) | An import `ThermalStandard` | | `Reserve` | No component. The record reaches `extensions.json`. Refer to [Not translated](#not-translated). | | [Region `VoLL`](#region-load--interruptiblepowerload) | The `operation_cost` of an `InterruptiblePowerLoad`, on a reliability run only. | -| `Zone`, `Interface`, `Transformer`, `Constraint`, `Waterway`, `Decision Variable` | [Not translated](#not-translated) | +| `Zone`, `Interface`, `Transformer`, `Constraint`, `Waterway`, `Decision Variable` | [Not translated](#not-translated). A `Constraint` reaches the sidecar, but nothing applies it. | | `Transmission`, `ST`/`MT Schedule`, `PASA`, `Production`, `Performance`, `Stochastic`, `Report`, `Diagnostic`, `System`, `List` | Not translated. These are solver settings, not model data. | ## Reading the tables @@ -504,7 +504,7 @@ energy than your model gives it. | `Zone` | The zonal group is lost. The regional group still becomes an `Area`. | | `Interface` | Nothing applies the group flow limits, so a transfer can go above a limit your model obeys. | | `Transformer` | The translator does not carry it. | -| `Constraint` | Nothing applies the custom constraints, which include the RPS targets and the emission targets. | +| `Constraint` | The record reaches `extensions.json`, but nothing applies the custom constraints, which include the RPS targets and the emission targets. | | `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 reaches the cost. | diff --git a/interop/core/extensions.py b/interop/core/extensions.py index 2868455..077a0c9 100644 --- a/interop/core/extensions.py +++ b/interop/core/extensions.py @@ -47,6 +47,10 @@ class ExtensionKind(StrEnum): # PLEXOS Reserve; Sienna VariableReserve and ConstantReserve. PyPSA has none, which is # why the concept needs the sidecar to survive a hop through it. RESERVE = "reserve" + # 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. @@ -80,6 +84,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.""" @@ -109,6 +132,9 @@ class ExpansionExtension(ExtensionRecord): # $/MW/yr. PyPSA's fom_cost is a charge for the whole modelled horizon, not a yearly # one, so a yearly charge has no field there. fom_charge_per_mw_year: float | None = None + # The year the object leaves service, which PLEXOS states as a dated Units of zero. + # PyPSA carries build_year on the component and nothing for the other end of its life. + retirement_year: int | None = None class GeneratorExtension(ExpansionExtension): @@ -193,6 +219,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.""" @@ -213,6 +278,7 @@ class Extensions(BaseModel): controllable_line: list[ControllableLineExtension] = [] storage: list[StorageExtension] = [] reserve: list[ReserveExtension] = [] + constraint: list[ConstraintExtension] = [] network: list[NetworkExtension] = [] @@ -224,6 +290,7 @@ class Extensions(BaseModel): ExtensionKind.CONTROLLABLE_LINE: ControllableLineExtension, ExtensionKind.STORAGE: StorageExtension, ExtensionKind.RESERVE: ReserveExtension, + ExtensionKind.CONSTRAINT: ConstraintExtension, ExtensionKind.NETWORK: NetworkExtension, } @@ -404,6 +471,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: ... @@ -468,6 +539,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_constants.py b/interop/plugins/shared/plexos_constants.py index e6a40f2..67797ed 100644 --- a/interop/plugins/shared/plexos_constants.py +++ b/interop/plugins/shared/plexos_constants.py @@ -40,10 +40,11 @@ class PlexosClass(StrEnum): class PlexosResolvedTable: - """Keys of the two long tables ``stage_plexos_xml`` resolves from the raw ``t_*`` tables.""" + """Keys of the long tables ``stage_plexos_xml`` resolves from the raw ``t_*`` tables.""" MEMBERSHIPS = "memberships" PROPERTIES = "properties" + DATED_PROPERTIES = "dated_properties" class PlexosObjectCol: @@ -95,6 +96,19 @@ class PlexosPropertyCol: SCALING = "scaling" +class PlexosDatedPropertyCol(PlexosPropertyCol): + """Columns of the resolved ``dated_properties`` table. + + The ``properties`` columns, plus the dates the value on the row applies between. Every + row of a property the model dates is here, whether or not the window being translated + covers its band, so a mapping reading a schedule sees the years the model states rather + than one value. + """ + + DATE_FROM = "date_from" + DATE_TO = "date_to" + + class PlexosCollection(StrEnum): """PLEXOS collection names, the relationship kind on a ``memberships`` row. @@ -211,6 +225,7 @@ class PlexosProperty(StrEnum): RHS_WEEK = "RHS Week" RHS_MONTH = "RHS Month" RHS_YEAR = "RHS Year" + INCLUDE_IN_LT_PLAN = "Include in LT Plan" def is_plexos_true(value: float) -> bool: diff --git a/interop/plugins/shared/plexos_dates.py b/interop/plugins/shared/plexos_dates.py new file mode 100644 index 0000000..dcb6aa1 --- /dev/null +++ b/interop/plugins/shared/plexos_dates.py @@ -0,0 +1,45 @@ +"""When a dated PLEXOS value applies.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from datetime import datetime, timedelta +from typing import NamedTuple, TypeVar + + +class DateBand(NamedTuple): + """When a ``t_data`` value applies. An open end runs from, or until, forever.""" + + date_from: datetime | None + date_to: datetime | None + + @property + def ends(self) -> datetime | None: + """A ``date_to`` names a whole day, so the band runs to the end of it.""" + return None if self.date_to is None else self.date_to + timedelta(days=1) + + def covers(self, moment: datetime) -> bool: + return (self.date_from is None or self.date_from <= moment) and ( + self.ends is None or moment < self.ends + ) + + +UNDATED = DateBand(None, None) + +T = TypeVar("T") + + +def latest_covering(bands: Sequence[tuple[DateBand, T]], moment: datetime) -> T | None: + covering = [value for band, value in bands if band.covers(moment)] + return covering[-1] if covering else None + + +def band_edges(bands: Iterable[tuple[DateBand, object]]) -> list[datetime]: + """Every moment a band opens or closes, earliest first.""" + moments = {edge for band, _ in bands for edge in (band.date_from, band.ends)} + return sorted(moment for moment in moments if moment is not None) + + +def opens_at(band: tuple[DateBand, object]) -> datetime: + """A sort key putting an undated band first, since it stands before any dated one begins.""" + return band[0].date_from or datetime.min diff --git a/interop/plugins/shared/plexos_pypsa_translations/_batteries.py b/interop/plugins/shared/plexos_pypsa_translations/_batteries.py index 503902a..24783af 100644 --- a/interop/plugins/shared/plexos_pypsa_translations/_batteries.py +++ b/interop/plugins/shared/plexos_pypsa_translations/_batteries.py @@ -20,6 +20,7 @@ derive_expansion, gather_sources, ) +from interop.plugins.shared.plexos_pypsa_translations._lifespan import derive_lifespan from interop.plugins.shared.plexos_pypsa_translations._shared import outage_time_series from interop.plugins.shared.plexos_pypsa_translations._storage_shared import ( CARRIER_NOTE, @@ -93,7 +94,7 @@ def record_battery_outages(state: State, mappings: list[StorageUnitMapping]) -> def map_battery(name: str, lookups: StorageLookups) -> MappedOrSkipped: - rated = rate_object(lookups.battery(name), _BATTERY_POWER) + rated = rate_object(lookups.staged(PlexosClass.BATTERY, name), _BATTERY_POWER) if isinstance(rated, SkippedComponent): return rated return _derive_battery(rated) @@ -128,6 +129,7 @@ def _derive_battery(rated: RatedObject) -> StorageUnitMapping: ), inflow=Decision.default(DEFAULT_INFLOW, NO_RESERVOIR_INFLOW_NOTE), cyclic=_battery_cyclic(rated), + lifespan=derive_lifespan(PlexosClass.BATTERY, rated.name, rated.lifespan), expansion=expansion, units=rated.properties.get(PlexosProperty.UNITS), ) 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/shared/plexos_pypsa_translations/_expansion.py b/interop/plugins/shared/plexos_pypsa_translations/_expansion.py index b43aceb..b978032 100644 --- a/interop/plugins/shared/plexos_pypsa_translations/_expansion.py +++ b/interop/plugins/shared/plexos_pypsa_translations/_expansion.py @@ -16,6 +16,9 @@ from interop.plugins.shared.plexos_pypsa_translations.constants import ( DEFAULT_UNITS, DIRECT_DERIVATION, + EXT_FOM_CHARGE_FIELD, + EXT_TECHNICAL_LIFE_FIELD, + EXT_UNIT_SIZE_FIELD, NOTHING_TO_BUILD, ) from interop.plugins.shared.plexos_pypsa_translations.decisions import ( @@ -71,11 +74,9 @@ "capacity is fixed" ) -UNIT_SIZE_COLUMN = MappedColumns(("extensions.unit_size_mw",), UNIT_MW) -TECHNICAL_LIFE_COLUMN = MappedColumns(("extensions.technical_life_years",), UNIT_YEARS) -FOM_CHARGE_COLUMN = MappedColumns( - ("extensions.fom_charge_per_mw_year",), UNIT_DOLLARS_PER_MW_YEAR -) +UNIT_SIZE_COLUMN = MappedColumns((EXT_UNIT_SIZE_FIELD,), UNIT_MW) +TECHNICAL_LIFE_COLUMN = MappedColumns((EXT_TECHNICAL_LIFE_FIELD,), UNIT_YEARS) +FOM_CHARGE_COLUMN = MappedColumns((EXT_FOM_CHARGE_FIELD,), UNIT_DOLLARS_PER_MW_YEAR) @dataclass(frozen=True) diff --git a/interop/plugins/shared/plexos_pypsa_translations/_generator_decisions.py b/interop/plugins/shared/plexos_pypsa_translations/_generator_decisions.py index 2cfadf3..a83ba06 100644 --- a/interop/plugins/shared/plexos_pypsa_translations/_generator_decisions.py +++ b/interop/plugins/shared/plexos_pypsa_translations/_generator_decisions.py @@ -42,6 +42,10 @@ ThermalCostTerms, UnitCommitment, ) +from interop.plugins.shared.plexos_pypsa_translations._lifespan import ( + LifespanDecisions, + derive_lifespan, +) from interop.plugins.shared.plexos_pypsa_translations.constants import ( FULL_AVAILABILITY, MARGINAL_COST_CARBON_TERM, @@ -150,6 +154,7 @@ class GeneratorDecisions: up_time_before: Decision = maps_to(PyPSAGeneratorCol.UP_TIME_BEFORE, unit=UNIT_SNAPSHOTS) start_up_cost: Decision = maps_to(PyPSAGeneratorCol.START_UP_COST, unit=UNIT_DOLLARS) shut_down_cost: Decision = maps_to(PyPSAGeneratorCol.SHUT_DOWN_COST, unit=UNIT_DOLLARS) + lifespan: LifespanDecisions = holds() expansion: ExpansionDecisions = holds() @@ -180,6 +185,7 @@ def decide_generator(mapping: GeneratorMapping) -> GeneratorDecisions: up_time_before=commitment.up_time_before, start_up_cost=commitment.start_up_cost, shut_down_cost=commitment.shut_down_cost, + lifespan=derive_lifespan(PlexosClass.GENERATOR, mapping.name, mapping.lifespan), expansion=mapping.expansion, ) diff --git a/interop/plugins/shared/plexos_pypsa_translations/_generator_derivation.py b/interop/plugins/shared/plexos_pypsa_translations/_generator_derivation.py index 02791b1..ae3234b 100644 --- a/interop/plugins/shared/plexos_pypsa_translations/_generator_derivation.py +++ b/interop/plugins/shared/plexos_pypsa_translations/_generator_derivation.py @@ -28,6 +28,7 @@ derive_p_nom, ) from interop.plugins.shared.plexos_pypsa_translations._generator_lookups import Lookups +from interop.plugins.shared.plexos_pypsa_translations._lifespan import NO_LIFESPAN, Lifespan from interop.plugins.shared.plexos_pypsa_translations.constants import ( DEFAULT_P_MIN_PU, DEFAULT_SHUT_DOWN_COST, @@ -102,6 +103,7 @@ class SourceGenerator: props: dict[str, float] stated_units: dict[str, str | None] max_capacity: float + lifespan: Lifespan @cached_property def candidate(self) -> CandidateSource: @@ -142,6 +144,7 @@ def read_source(generator: dict[str, Any], name: str, lookups: Lookups) -> Sourc props=props, stated_units=lookups.gen_units.get(name, {}), max_capacity=_rated_capacity(name, props, lookups), + lifespan=lookups.lifespans.get(name, NO_LIFESPAN), ) @@ -212,6 +215,7 @@ class GeneratorMapping: unit_commitment: UnitCommitment | None candidate: CandidateSource expansion: ExpansionDecisions + lifespan: Lifespan @property def is_committable(self) -> bool: @@ -258,6 +262,7 @@ def derive_generator(source: SourceGenerator, node: str, lookups: Lookups) -> Ge else None, candidate=candidate, expansion=derive_expansion(candidate), + lifespan=source.lifespan, ) diff --git a/interop/plugins/shared/plexos_pypsa_translations/_generator_lookups.py b/interop/plugins/shared/plexos_pypsa_translations/_generator_lookups.py index 73ba10d..8ddb0a3 100644 --- a/interop/plugins/shared/plexos_pypsa_translations/_generator_lookups.py +++ b/interop/plugins/shared/plexos_pypsa_translations/_generator_lookups.py @@ -1,6 +1,6 @@ """The per-class lookups the generator mapping reads while it walks the Generator class. -Each is built once from the two long staged tables, so mapping a generator is dictionary +Each is built once from the long staged tables, so mapping a generator is dictionary reads rather than a scan per generator. """ @@ -18,6 +18,10 @@ PlexosProperty, PlexosResolvedTable, ) +from interop.plugins.shared.plexos_pypsa_translations._lifespan import ( + Lifespan, + read_lifespans, +) from interop.plugins.shared.plexos_pypsa_translations._shared import ( MultiValueRule, ObjectProperties, @@ -65,6 +69,7 @@ class Lookups: profile_peaks: dict[str, dict[str, float]] capacity_peaks: dict[str, float] dated_fuel_prices: dict[str, float] + lifespans: dict[str, Lifespan] minutes_per_snapshot: float @@ -90,6 +95,9 @@ def build_lookups(state: State) -> Lookups: profile_peaks={prop: _series_peaks(state, prop) for prop in _PROFILE_PROPERTIES}, capacity_peaks=_series_peaks(state, PlexosProperty.MAX_CAPACITY), dated_fuel_prices=_mean_fuel_prices(state), + lifespans=read_lifespans( + state.source_topology[PlexosResolvedTable.DATED_PROPERTIES], PlexosClass.GENERATOR + ), # Every staged series shares the network's snapshots, so any of them fixes the # resolution the hour-based generator properties convert against. minutes_per_snapshot=resolution_minutes( diff --git a/interop/plugins/shared/plexos_pypsa_translations/_generators.py b/interop/plugins/shared/plexos_pypsa_translations/_generators.py index ba36270..8f2ad59 100644 --- a/interop/plugins/shared/plexos_pypsa_translations/_generators.py +++ b/interop/plugins/shared/plexos_pypsa_translations/_generators.py @@ -44,6 +44,10 @@ Lookups, build_lookups, ) +from interop.plugins.shared.plexos_pypsa_translations._lifespan import ( + read_year, + record_lifespan, +) from interop.plugins.shared.plexos_pypsa_translations._shared import outage_time_series from interop.plugins.shared.plexos_pypsa_translations._storage_turbines import ( storage_turbine_names, @@ -126,9 +130,8 @@ def map_generators(state: State, recorder: ScopedRecorder) -> None: ], GENERATORS_DESTINATION_SCHEMA, ) - mappings = [one.mapping for one in translated] - _carry_to_extensions(state, mappings, reporter) - _record_availability_time_series(state, mappings, reporter) + _carry_to_extensions(state, translated, reporter) + _record_availability_time_series(state, [one.mapping for one in translated], reporter) @dataclass(frozen=True) @@ -140,26 +143,29 @@ class _TranslatedGenerator: def _carry_to_extensions( - state: State, mappings: list[GeneratorMapping], reporter: ComponentReporter + state: State, translated: list[_TranslatedGenerator], reporter: ComponentReporter ) -> None: """Put what the network file cannot hold in the sidecar, starting with the PLEXOS category, since only one of it and the fuel could become the carrier. """ - for mapping in mappings: + for one in translated: + mapping = one.mapping if mapping.carrier != mapping.category: reporter.record(mapping.name, _CATEGORY_COLUMN, _category_decision(mapping)) record_expansion(mapping.name, mapping.expansion, reporter) + record_lifespan(mapping.name, one.decisions.lifespan, reporter) records = [ GeneratorExtension( - name=mapping.name, - category=mapping.category, - unit_size_mw=read_sidecar_value(mapping.expansion.unit_size), - technical_life_years=read_sidecar_value(mapping.expansion.technical_life), - fom_charge_per_mw_year=read_sidecar_value(mapping.expansion.fom_charge), + name=one.mapping.name, + category=one.mapping.category, + unit_size_mw=read_sidecar_value(one.mapping.expansion.unit_size), + technical_life_years=read_sidecar_value(one.mapping.expansion.technical_life), + fom_charge_per_mw_year=read_sidecar_value(one.mapping.expansion.fom_charge), + retirement_year=read_year(one.decisions.lifespan.retirement_year), ) - for mapping in mappings + for one in translated ] - warn_about_dropped_builds(mapping.expansion for mapping in mappings) + warn_about_dropped_builds(one.mapping.expansion for one in translated) append_extensions(state.destination_extensions, ExtensionKind.GENERATOR, records) diff --git a/interop/plugins/shared/plexos_pypsa_translations/_lifespan.py b/interop/plugins/shared/plexos_pypsa_translations/_lifespan.py new file mode 100644 index 0000000..0a68d98 --- /dev/null +++ b/interop/plugins/shared/plexos_pypsa_translations/_lifespan.py @@ -0,0 +1,187 @@ +"""When a PLEXOS object comes into service, and when it goes out of it. + +A model writes an expansion plan as a schedule of dated ``Units``: an object running none +of itself until a later year arrives in that year, and one whose units fall back to zero +leaves in the year they do. The window being translated holds one value per property, so +these years are read from the dated bands the source stages beside it, unclipped. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import NamedTuple + +import polars as pl + +from interop.plugins.shared.plexos_constants import ( + PlexosClass, + PlexosDatedPropertyCol, + PlexosProperty, +) +from interop.plugins.shared.plexos_dates import ( + DateBand, + band_edges, + latest_covering, + opens_at, +) +from interop.plugins.shared.plexos_pypsa_translations._expansion import NOTHING_TO_REPORT +from interop.plugins.shared.plexos_pypsa_translations.constants import ( + EXT_RETIREMENT_YEAR_FIELD, +) +from interop.plugins.shared.plexos_pypsa_translations.decisions import ( + ComponentReporter, + Decision, + MappedColumns, + SourceValue, + maps_to, +) +from interop.plugins.shared.pypsa_constants import PyPSAGeneratorCol + +# What a schedule runs while no band it states covers the moment. +_NOT_IN_SERVICE = 0.0 + +RETIREMENT_YEAR_COLUMN = MappedColumns((EXT_RETIREMENT_YEAR_FIELD,)) + +_BUILD_DERIVATION = "the first year the dated Units rise above zero" +_RETIREMENT_DERIVATION = "the last year the dated Units fall back to zero" + + +class Milestone(NamedTuple): + """A year a schedule changes what an object runs, and what it runs from then on.""" + + year: int + units: float + + +class Lifespan(NamedTuple): + """When a dated ``Units`` schedule brings an object in, and when it takes it out.""" + + build: Milestone | None + retirement: Milestone | None + + +NO_LIFESPAN = Lifespan(None, None) + + +@dataclass(frozen=True) +class LifespanDecisions: + build_year: Decision = maps_to(PyPSAGeneratorCol.BUILD_YEAR) + # The sidecar carries the retirement year, since PyPSA has no column for it. + retirement_year: Decision = NOTHING_TO_REPORT + + +class _UnitsBand(NamedTuple): + dates: DateBand + units: float + + +class _Change(NamedTuple): + """A moment a schedule changes what it runs, and what it ran until then.""" + + at: datetime + units: float + was: float + + +def read_lifespans(dated: pl.LazyFrame, plexos_class: PlexosClass) -> dict[str, Lifespan]: + return {name: _read_lifespan(bands) for name, bands in _read_bands(dated, plexos_class).items()} + + +def derive_lifespan(plexos_class: PlexosClass, name: str, lifespan: Lifespan) -> LifespanDecisions: + return LifespanDecisions( + build_year=_derive_year(plexos_class, name, lifespan.build, _BUILD_DERIVATION), + retirement_year=_derive_year( + plexos_class, name, lifespan.retirement, _RETIREMENT_DERIVATION + ), + ) + + +def record_lifespan(name: str, decisions: LifespanDecisions, reporter: ComponentReporter) -> None: + reporter.record(name, RETIREMENT_YEAR_COLUMN, decisions.retirement_year) + + +def _derive_year( + plexos_class: PlexosClass, name: str, milestone: Milestone | None, derivation: str +) -> Decision: + if milestone is None: + return Decision.unreported(None) + source = SourceValue(plexos_class, name, PlexosProperty.UNITS, milestone.units) + return Decision.derived(milestone.year, [source], derivation) + + +def read_year(decision: Decision) -> int | None: + return None if decision.value is None else int(decision.value) + + +def _read_bands(dated: pl.LazyFrame, plexos_class: PlexosClass) -> dict[str, list[_UnitsBand]]: + """Each object's ``Units`` rows, earliest band first, for the objects that date any. + + One row per object per band, so the frame is component-scale and safe to collect. + """ + if PlexosDatedPropertyCol.CHILD_CLASS not in dated.collect_schema().names(): + return {} + frame = ( + dated.filter( + (pl.col(PlexosDatedPropertyCol.CHILD_CLASS) == plexos_class) + & (pl.col(PlexosDatedPropertyCol.PROPERTY) == PlexosProperty.UNITS) + & pl.col(PlexosDatedPropertyCol.VALUE).is_not_null() + ) + .select( + PlexosDatedPropertyCol.CHILD_OBJECT, + PlexosDatedPropertyCol.DATE_FROM, + PlexosDatedPropertyCol.DATE_TO, + PlexosDatedPropertyCol.VALUE, + ) + .collect() + ) + bands: dict[str, list[_UnitsBand]] = {} + for name, date_from, date_to, units in frame.iter_rows(): + bands.setdefault(name, []).append(_UnitsBand(DateBand(date_from, date_to), units)) + return {name: sorted(rows, key=opens_at) for name, rows in bands.items()} + + +def _read_lifespan(bands: list[_UnitsBand]) -> Lifespan: + """An object running before its first dated change was built before the model starts, + and one running again after a zero band was mothballed rather than retired, so a + schedule that runs at both ends of itself states neither year. + """ + changes = _changes(bands) + if not changes: + return NO_LIFESPAN + build = _first_start(changes) if not _runs(changes[0].was) else None + retirement = _last_stop(changes) if not _runs(changes[-1].units) else None + return Lifespan(_milestone(build), _milestone(retirement)) + + +def _first_start(changes: list[_Change]) -> _Change | None: + return next((one for one in changes if _runs(one.units)), None) + + +def _last_stop(changes: list[_Change]) -> _Change | None: + return next((one for one in reversed(changes) if _runs(one.was) and not _runs(one.units)), None) + + +def _milestone(change: _Change | None) -> Milestone | None: + return None if change is None else Milestone(change.at.year, change.units) + + +def _runs(units: float) -> bool: + return units > _NOT_IN_SERVICE + + +def _changes(bands: list[_UnitsBand]) -> list[_Change]: + """What the schedule runs from each of its edges, beside what it ran before that edge.""" + changes: list[_Change] = [] + running = _units_at(bands, datetime.min) + for moment in band_edges(bands): + units = _units_at(bands, moment) + changes.append(_Change(moment, units, running)) + running = units + return changes + + +def _units_at(bands: list[_UnitsBand], moment: datetime) -> float: + """What the schedule runs at a moment; no band covering it means none of the object.""" + units = latest_covering(bands, moment) + return _NOT_IN_SERVICE if units is None else units diff --git a/interop/plugins/shared/plexos_pypsa_translations/_storage_hydro.py b/interop/plugins/shared/plexos_pypsa_translations/_storage_hydro.py index c977bab..b8289e0 100644 --- a/interop/plugins/shared/plexos_pypsa_translations/_storage_hydro.py +++ b/interop/plugins/shared/plexos_pypsa_translations/_storage_hydro.py @@ -24,6 +24,9 @@ from interop.plugins.shared.plexos_pypsa_translations._expansion import ( derive_expansion, ) +from interop.plugins.shared.plexos_pypsa_translations._lifespan import ( + derive_lifespan, +) from interop.plugins.shared.plexos_pypsa_translations._storage_shared import ( CARRIER_NOTE, CHARGE_NOTE, @@ -166,7 +169,7 @@ class _GeneratorStorageVariant: def map_turbine(name: str, lookups: StorageLookups) -> MappedOrSkipped: - staged = lookups.generator(name) + staged = lookups.staged(PlexosClass.GENERATOR, name) variant = _classify_turbine(staged, lookups) if variant is None: return skip_object( @@ -218,6 +221,7 @@ def _derive_turbine( ), inflow=_reservoir_inflow(head), cyclic=Decision.default(variant.cyclic, variant.cyclic_note), + lifespan=derive_lifespan(PlexosClass.GENERATOR, rated.name, rated.lifespan), expansion=expansion, inflow_storage=_inflow_storage(head), ) diff --git a/interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py b/interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py index af1a732..6690170 100644 --- a/interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py +++ b/interop/plugins/shared/plexos_pypsa_translations/_storage_shared.py @@ -11,6 +11,8 @@ from dataclasses import dataclass from math import sqrt +import polars as pl + from interop.core.pipeline import State from interop.plugins.shared.constants import ( UNIT_DOLLARS_PER_MWH, @@ -32,6 +34,12 @@ derive_p_nom, find_unpriced_candidate, ) +from interop.plugins.shared.plexos_pypsa_translations._lifespan import ( + NO_LIFESPAN, + Lifespan, + LifespanDecisions, + read_lifespans, +) from interop.plugins.shared.plexos_pypsa_translations._shared import ( ObjectProperties, ObjectUnits, @@ -115,6 +123,7 @@ class StorageUnitMapping: ) inflow: Decision = maps_to(PyPSAStorageUnitCol.INFLOW, unit=UNIT_MW) cyclic: Decision = maps_to(PyPSAStorageUnitCol.CYCLIC_STATE_OF_CHARGE) + lifespan: LifespanDecisions = holds() expansion: ExpansionDecisions = holds() # Units is a Battery-only reading; a units-out trace derates against it. units: float | None = None @@ -170,42 +179,47 @@ class StagedObject: stated_units: dict[str, str | None] node: str | None file_backed: list[str] + lifespan: Lifespan + + +@dataclass(frozen=True) +class ClassLookups: + """What one PLEXOS class states about each of its objects.""" + + properties: ObjectProperties + stated_units: ObjectUnits + nodes: dict[str, str] + file_backed: dict[str, list[str]] + lifespans: dict[str, Lifespan] @dataclass(frozen=True) class StorageLookups: - """The per-object properties and memberships the three storage paths read.""" + """The per-object properties and memberships the three storage paths read. + + ``by_class`` holds the Battery and the Generator only, because a Storage states no + capacity of its own and its properties stand beside it in ``storage_properties``. + """ - battery_properties: ObjectProperties - generator_properties: ObjectProperties + by_class: dict[PlexosClass, ClassLookups] storage_properties: ObjectProperties - node_by_battery: dict[str, str] - node_by_generator: dict[str, str] + storage_units: ObjectUnits head_by_generator: dict[str, str] tail_by_generator: dict[str, str] - file_backed_by_battery: dict[str, list[str]] - file_backed_by_generator: dict[str, list[str]] - battery_units: ObjectUnits - generator_units: ObjectUnits - storage_units: ObjectUnits storages_with_inflow_profile: set[str] - def battery(self, name: str) -> StagedObject: - return StagedObject( - name=name, - properties=self.battery_properties.get(name, {}), - stated_units=self.battery_units.get(name, {}), - node=self.node_by_battery.get(name), - file_backed=self.file_backed_by_battery.get(name, []), - ) + def properties_of(self, plexos_class: PlexosClass) -> ObjectProperties: + return self.by_class[plexos_class].properties - def generator(self, name: str) -> StagedObject: + def staged(self, plexos_class: PlexosClass, name: str) -> StagedObject: + one = self.by_class[plexos_class] return StagedObject( name=name, - properties=self.generator_properties.get(name, {}), - stated_units=self.generator_units.get(name, {}), - node=self.node_by_generator.get(name), - file_backed=self.file_backed_by_generator.get(name, []), + properties=one.properties.get(name, {}), + stated_units=one.stated_units.get(name, {}), + node=one.nodes.get(name), + file_backed=one.file_backed.get(name, []), + lifespan=one.lifespans.get(name, NO_LIFESPAN), ) def has_head_and_tail(self, generator: str) -> bool: @@ -278,27 +292,39 @@ def _states_inflow_in_other_units(volumes: dict[str, float], units: dict[str, st def build_lookups(state: State) -> StorageLookups: properties = state.source_topology[PlexosResolvedTable.PROPERTIES] memberships = state.source_topology[PlexosResolvedTable.MEMBERSHIPS] + dated = state.source_topology[PlexosResolvedTable.DATED_PROPERTIES] return StorageLookups( - battery_properties=collapse_properties_by_object(properties, PlexosClass.BATTERY), - generator_properties=collapse_properties_by_object(properties, PlexosClass.GENERATOR), + by_class={ + plexos_class: _read_class(properties, memberships, dated, plexos_class) + for plexos_class in (PlexosClass.BATTERY, PlexosClass.GENERATOR) + }, storage_properties=collapse_properties_by_object(properties, PlexosClass.STORAGE), - node_by_battery=relate_child(memberships, PlexosClass.BATTERY, PlexosCollection.NODES), - node_by_generator=relate_child(memberships, PlexosClass.GENERATOR, PlexosCollection.NODES), + storage_units=collapse_units_by_object(properties, PlexosClass.STORAGE), head_by_generator=relate_child( memberships, PlexosClass.GENERATOR, PlexosCollection.HEAD_STORAGE ), tail_by_generator=relate_child( memberships, PlexosClass.GENERATOR, PlexosCollection.TAIL_STORAGE ), - file_backed_by_battery=read_file_backed_properties(properties, PlexosClass.BATTERY), - file_backed_by_generator=read_file_backed_properties(properties, PlexosClass.GENERATOR), - battery_units=collapse_units_by_object(properties, PlexosClass.BATTERY), - generator_units=collapse_units_by_object(properties, PlexosClass.GENERATOR), - storage_units=collapse_units_by_object(properties, PlexosClass.STORAGE), storages_with_inflow_profile=_storages_with_inflow_profile(state), ) +def _read_class( + properties: pl.LazyFrame, + memberships: pl.LazyFrame, + dated: pl.LazyFrame, + plexos_class: PlexosClass, +) -> ClassLookups: + return ClassLookups( + properties=collapse_properties_by_object(properties, plexos_class), + stated_units=collapse_units_by_object(properties, plexos_class), + nodes=relate_child(memberships, plexos_class, PlexosCollection.NODES), + file_backed=read_file_backed_properties(properties, plexos_class), + lifespans=read_lifespans(dated, plexos_class), + ) + + def _storages_with_inflow_profile(state: State) -> set[str]: """The Storages whose Natural Inflow arrives as a staged time series.""" frame = state.source_time_series.get((PlexosClass.STORAGE, PlexosProperty.NATURAL_INFLOW)) @@ -346,6 +372,7 @@ class RatedObject: node: str p_nom: Decision candidate: CandidateSource + lifespan: Lifespan @property def name(self) -> str: @@ -386,7 +413,7 @@ def rate_object(staged: StagedObject, rating: RatedPower) -> RatedObject | Skipp p_nom = derive_p_nom(candidate) if p_nom.value <= 0.0: return _skipped_zero_p_nom(rating, staged.name, p_nom.value) - return RatedObject(staged.node, p_nom, candidate) + return RatedObject(staged.node, p_nom, candidate, staged.lifespan) def skip_object( diff --git a/interop/plugins/shared/plexos_pypsa_translations/_storage_units.py b/interop/plugins/shared/plexos_pypsa_translations/_storage_units.py index ee35dde..5f8e503 100644 --- a/interop/plugins/shared/plexos_pypsa_translations/_storage_units.py +++ b/interop/plugins/shared/plexos_pypsa_translations/_storage_units.py @@ -31,6 +31,10 @@ record_expansion, warn_about_dropped_builds, ) +from interop.plugins.shared.plexos_pypsa_translations._lifespan import ( + read_year, + record_lifespan, +) from interop.plugins.shared.plexos_pypsa_translations._shared import ( ObjectProperties, ) @@ -180,7 +184,7 @@ def _dropped_values(state: State, lookups: StorageLookups) -> list[_DroppedValue *_dropped_from( read_object_names(state, PlexosClass.BATTERY), PlexosClass.BATTERY, - lookups.battery_properties, + lookups.properties_of(PlexosClass.BATTERY), _BATTERY_DROPPED, ), *_dropped_from(storages, PlexosClass.STORAGE, lookups.storage_properties, _STORAGE_DROPPED), @@ -226,6 +230,7 @@ def _record(storage_units: _DerivedStorageUnits, recorder: ScopedRecorder) -> No for mapping in storage_units.mappings: reporter.record_mapping(mapping.name, mapping) record_expansion(mapping.name, mapping.expansion, reporter) + record_lifespan(mapping.name, mapping.lifespan, reporter) warn_about_dropped_builds(mapping.expansion for mapping in storage_units.mappings) for skipped in storage_units.skipped: reporter.record_skipped(skipped.source, skipped.note) @@ -240,6 +245,7 @@ def _carry_to_extensions(state: State, mappings: list[StorageUnitMapping]) -> No name=mapping.name, unit_size_mw=read_sidecar_value(mapping.expansion.unit_size), technical_life_years=read_sidecar_value(mapping.expansion.technical_life), + retirement_year=read_year(mapping.lifespan.retirement_year), fom_charge_per_mw_year=read_sidecar_value(mapping.expansion.fom_charge), ) for mapping in mappings diff --git a/interop/plugins/shared/plexos_pypsa_translations/constants.py b/interop/plugins/shared/plexos_pypsa_translations/constants.py index 8ac678a..ac3201e 100644 --- a/interop/plugins/shared/plexos_pypsa_translations/constants.py +++ b/interop/plugins/shared/plexos_pypsa_translations/constants.py @@ -17,6 +17,12 @@ DEFAULT_UNITS: float = 1.0 """A generator with no ``Units`` property is a single unit.""" +# Each names a sidecar key in the audit trail; the network file itself has no such column. +EXT_UNIT_SIZE_FIELD: str = "extensions.unit_size_mw" +EXT_TECHNICAL_LIFE_FIELD: str = "extensions.technical_life_years" +EXT_RETIREMENT_YEAR_FIELD: str = "extensions.retirement_year" +EXT_FOM_CHARGE_FIELD: str = "extensions.fom_charge_per_mw_year" + # --- generators --------------------------------------------------------------- DEFAULT_UP_TIME_BEFORE: float = 0.0 diff --git a/interop/plugins/shared/pypsa_constants.py b/interop/plugins/shared/pypsa_constants.py index 709e38f..14d0619 100644 --- a/interop/plugins/shared/pypsa_constants.py +++ b/interop/plugins/shared/pypsa_constants.py @@ -256,6 +256,7 @@ class PyPSAGeneratorCol(PyPSAComponentCol): CAPITAL_COST = "capital_cost" LIFETIME = "lifetime" FOM_COST = "fom_cost" + BUILD_YEAR = "build_year" class PyPSAStorageUnitCol(PyPSAComponentCol): @@ -282,6 +283,7 @@ class PyPSAStorageUnitCol(PyPSAComponentCol): CAPITAL_COST = "capital_cost" LIFETIME = "lifetime" FOM_COST = "fom_cost" + BUILD_YEAR = "build_year" class PyPSAStoreCol(PyPSAComponentCol): @@ -486,6 +488,7 @@ class ReverseTimeSeriesMetadataCol: PyPSAGeneratorCol.OVERNIGHT_COST: pl.Float64, PyPSAGeneratorCol.DISCOUNT_RATE: pl.Float64, PyPSAGeneratorCol.LIFETIME: pl.Float64, + PyPSAGeneratorCol.BUILD_YEAR: pl.Int64, } # Unit-commitment columns a non-committable generator leaves unset; the sink omits null @@ -522,6 +525,7 @@ class ReverseTimeSeriesMetadataCol: PyPSAStorageUnitCol.OVERNIGHT_COST: pl.Float64, PyPSAStorageUnitCol.DISCOUNT_RATE: pl.Float64, PyPSAStorageUnitCol.LIFETIME: pl.Float64, + PyPSAStorageUnitCol.BUILD_YEAR: pl.Int64, } LOADS_DESTINATION_SCHEMA: dict[str, pl.DataType | type[pl.DataType]] = { diff --git a/interop/plugins/sinks/emit_pypsa_network.py b/interop/plugins/sinks/emit_pypsa_network.py index 7cae328..4032620 100644 --- a/interop/plugins/sinks/emit_pypsa_network.py +++ b/interop/plugins/sinks/emit_pypsa_network.py @@ -302,6 +302,7 @@ def _add_generators(network: pypsa.Network, generators: pl.DataFrame | None) -> PyPSAGeneratorCol.OVERNIGHT_COST, PyPSAGeneratorCol.DISCOUNT_RATE, PyPSAGeneratorCol.LIFETIME, + PyPSAGeneratorCol.BUILD_YEAR, ), ) @@ -337,6 +338,7 @@ def _add_storage_units(network: pypsa.Network, storage_units: pl.DataFrame | Non PyPSAStorageUnitCol.OVERNIGHT_COST, PyPSAStorageUnitCol.DISCOUNT_RATE, PyPSAStorageUnitCol.LIFETIME, + PyPSAStorageUnitCol.BUILD_YEAR, ), ) diff --git a/interop/plugins/sources/plexos_dated_properties.py b/interop/plugins/sources/plexos_dated_properties.py index a4e492b..f2c6359 100644 --- a/interop/plugins/sources/plexos_dated_properties.py +++ b/interop/plugins/sources/plexos_dated_properties.py @@ -10,13 +10,24 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta +from datetime import datetime from typing import Any, NamedTuple import polars as pl from interop.plugins.shared.constants import StagedTimeSeriesCol -from interop.plugins.shared.plexos_constants import PlexosMembershipCol, PlexosPropertyCol +from interop.plugins.shared.plexos_constants import ( + PlexosDatedPropertyCol, + PlexosMembershipCol, + PlexosPropertyCol, +) +from interop.plugins.shared.plexos_dates import ( + UNDATED, + DateBand, + band_edges, + latest_covering, + opens_at, +) from interop.plugins.sources.plexos_horizon import Window from interop.plugins.sources.plexos_tables import Rows, RowsByTable @@ -39,26 +50,6 @@ _NOT_IN_EFFECT = 0.0 -class DateBand(NamedTuple): - """When a ``t_data`` value applies. An open end runs from, or until, forever.""" - - date_from: datetime | None - date_to: datetime | None - - @property - def ends(self) -> datetime | None: - """A ``date_to`` names a whole day, so the band runs to the end of it.""" - return None if self.date_to is None else self.date_to + timedelta(days=1) - - def covers(self, moment: datetime) -> bool: - return (self.date_from is None or self.date_from <= moment) and ( - self.ends is None or moment < self.ends - ) - - -UNDATED = DateBand(None, None) - - class DatedRow(NamedTuple): """One resolved property row, and when the value on it applies.""" @@ -85,60 +76,73 @@ def apply_window(resolved: list[DatedRow], window: Window) -> tuple[Rows, Rows]: in_force: Rows = [] stepped: Rows = [] for dated_rows in by_property.values(): - steps = _steps_within(sorted(dated_rows, key=_band_order), window) + steps = _steps_within(sorted(dated_rows, key=opens_at), window) in_force.append(steps[0].row) if len(steps) > 1: stepped.extend({**step.row, StagedTimeSeriesCol.SNAPSHOT: step.at} for step in steps) return in_force, stepped -def _property_identity(row: dict[str, Any]) -> tuple[Any, ...]: - """What makes a property one property: its membership, its name, and its band.""" +def dated_rows(resolved: list[DatedRow]) -> Rows: + """Every row of a property the model dates, beside the dates it applies between. + + ``apply_window`` reads one value per property for the window being translated, which + loses the years the bands outside it name. A schedule stated as a series of bands is + read from these rows instead. A property the model dates nowhere states the same value + for all time, which ``properties`` already carries. + """ + by_property: dict[tuple[Any, ...], list[DatedRow]] = {} + for dated in resolved: + by_property.setdefault(_property_name(dated.row), []).append(dated) + return [ + { + **dated.row, + PlexosDatedPropertyCol.DATE_FROM: dated.dates.date_from, + PlexosDatedPropertyCol.DATE_TO: dated.dates.date_to, + } + for group in by_property.values() + if any(dated.dates != UNDATED for dated in group) + for dated in group + ] + + +def _property_name(row: dict[str, Any]) -> tuple[Any, ...]: + """What names one property across its bands: its membership and its property name.""" return ( *(row[column] for column in _MEMBERSHIP_NAME_COLUMNS), row[PlexosPropertyCol.PROPERTY], - row[PlexosPropertyCol.BAND], ) -def _band_order(dated: DatedRow) -> datetime: - """Undated values sort first, being in force before any dated band begins.""" - return dated.dates.date_from or datetime.min +def _property_identity(row: dict[str, Any]) -> tuple[Any, ...]: + """What makes a property one property: its membership, its name, and its band.""" + return (*_property_name(row), row[PlexosPropertyCol.BAND]) def _steps_within(ordered: list[DatedRow], window: Window) -> list[_Step]: """One step per moment the property's value changes inside the window.""" template = ordered[0].row - outside = _stated_for_no_date(ordered) return [ - _Step(moment, {**template, PlexosPropertyCol.VALUE: _value_at(ordered, outside, moment)}) + _Step(moment, {**template, PlexosPropertyCol.VALUE: _value_at(ordered, moment)}) for moment in _change_moments(ordered, window) ] -def _stated_for_no_date(ordered: list[DatedRow]) -> DatedRow | None: - return next((dated for dated in ordered if dated.dates == UNDATED), None) - - -def _value_at(ordered: list[DatedRow], outside: DatedRow | None, moment: datetime) -> float | None: - """The latest band covering the moment, else the value stated for no date, else none. - - A property stated only for a period is not in effect outside one, and a property with - no value in effect is a property the model is not applying: it reads as zero. +def _value_at(ordered: list[DatedRow], moment: datetime) -> float | None: + """A property stated only for a period is not in effect outside one, and a property + with no value in effect is a property the model is not applying: it reads as zero. """ - covering = [dated for dated in ordered if dated.dates.covers(moment)] - stating = covering[-1] if covering else outside + stating = latest_covering(ordered, moment) if stating is None: return _NOT_IN_EFFECT - value: float | None = stating.row[PlexosPropertyCol.VALUE] + value: float | None = stating[PlexosPropertyCol.VALUE] return value def _change_moments(ordered: list[DatedRow], window: Window) -> list[datetime]: """When the window opens, and every band edge inside it.""" moments = {window.start} - edges = (edge for dated in ordered for edge in (dated.dates.date_from, dated.dates.ends)) - moments.update(edge for edge in edges if edge is not None and window.start < edge < window.end) + moments.update(edge for edge in band_edges(ordered) if window.start < edge < window.end) return sorted(moments) diff --git a/interop/plugins/sources/stage_plexos_xml.py b/interop/plugins/sources/stage_plexos_xml.py index 5876cf8..4182ffb 100644 --- a/interop/plugins/sources/stage_plexos_xml.py +++ b/interop/plugins/sources/stage_plexos_xml.py @@ -10,6 +10,8 @@ ``t_data`` value under the selected Model's Scenario overlays (highest Read Order wins, base value otherwise), with a ``data_file`` path where the value is file-backed; +- ``topology/dated_properties.parquet``, every row of a property the model dates + before it is narrowed to the window, each beside the dates it applies between; - one ``source_time_series`` frame per (owner class, property) whose value comes from an external CSV, streamed in as ``(snapshot, component, sample, value)`` rows. ``sample`` is null except where the CSV carries one column per Monte Carlo @@ -46,6 +48,7 @@ PlexosPropertyCol, PlexosResolvedTable, ) +from interop.plugins.shared.plexos_dates import UNDATED, DateBand from interop.plugins.shared.plexos_units import ( StatedValue, UnitConversions, @@ -62,11 +65,10 @@ warn_unstageable_layout, ) from interop.plugins.sources.plexos_dated_properties import ( - UNDATED, - DateBand, DatedRow, apply_window, date_bands, + dated_rows, stepped_series_parts, ) from interop.plugins.sources.plexos_horizon import Chronology, Window, reindex_onto @@ -255,8 +257,10 @@ def _stage_topology( topology = self._stage(resolved.objects_by_class, staging_dir) memberships = PlexosResolvedTable.MEMBERSHIPS properties = PlexosResolvedTable.PROPERTIES + dated = PlexosResolvedTable.DATED_PROPERTIES topology[memberships] = self._stage_table(memberships, resolved.memberships, staging_dir) topology[properties] = self._stage_table(properties, resolved.properties, staging_dir) + topology[dated] = self._stage_table(dated, resolved.dated_properties, staging_dir) return topology def _stage(self, objects_by_class: _RowsByClass, staging_dir: Path) -> dict[str, pl.LazyFrame]: @@ -376,17 +380,20 @@ class StagePlexosXmlEnsemble(StagePlexosXml): @dataclass(frozen=True) class _ResolvedDataset: - """The three staged topology tables, resolved from the raw ``t_*`` tables. + """The staged topology tables, resolved from the raw ``t_*`` tables. ``properties`` holds one row per property, the value in force when the window opens. ``stepped_properties`` holds every value a property takes within the window, and only for the properties that take more than one, so a consumer can read the shape. + ``dated_properties`` holds every row of a property the model dates, with the dates it + applies between, narrowed to no window at all. """ objects_by_class: _RowsByClass memberships: _Rows properties: _Rows stepped_properties: _Rows + dated_properties: _Rows def _resolve_dataset( @@ -400,6 +407,7 @@ def _resolve_dataset( memberships=memberships, properties=in_force, stepped_properties=stepped, + dated_properties=dated_rows(resolved), ) 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 3bf2c54..3196001 100644 --- a/interop/plugins/steps/pypsa_to_sienna_map_components.py +++ b/interop/plugins/steps/pypsa_to_sienna_map_components.py @@ -217,6 +217,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) # A record no mapping here read is dropped and reported, rather than relayed into a # sidecar this hop's reader cannot say anything about. @@ -532,6 +533,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 diff --git a/tests/features/plexos_to_pypsa/dated_properties.feature b/tests/features/plexos_to_pypsa/dated_properties.feature index 9f4420f..e43e913 100644 --- a/tests/features/plexos_to_pypsa/dated_properties.feature +++ b/tests/features/plexos_to_pypsa/dated_properties.feature @@ -123,3 +123,61 @@ Feature: a PLEXOS property dated to a period is read for the year being translat When I run translate against "inputs/model.xml" pipeline "plexos-to-pypsa" for model "Plan" year 2026 sink output "outputs/network.nc" Then the PyPSA generator "GasPlant" in "outputs/network.nc" has "marginal_cost" equal to 45 And the file "decisions.md" contains "the mean of the fuel's own dated price series" + + Scenario: a generator whose units start at zero and rise in a later year is built in it + The plan commits this candidate for 2032, which the 2026 network still records, so a + reader knows the year the model brings it into service. + 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 "REZ" with "node=Grid_Node, category=Wind, Max Capacity=100, Units=0, Max Units Built=1, Build Cost=900000, WACC=0.07, Economic Life=25" + And generator "REZ" states "Units" of 1 from "2032-01-01" + And the model contains model "Plan" + And the model contains horizon "H1" on model "Plan" starting "2026-01-01" spanning 2 days at 24 periods per day + And the model is saved as "inputs/built_later.xml" + When I run translate against "inputs/built_later.xml" pipeline "plexos-to-pypsa" for model "Plan" year 2026 sink output "outputs/network.nc" + Then the PyPSA network "outputs/network.nc" generator "REZ" attribute "build_year" is 2032 + And the file "decisions.md" contains "the first year the dated Units rise above zero" + + Scenario: a generator whose units fall to zero in a later year retires in it + PyPSA has no retirement year, so the sidecar carries it. + 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 "OldCoal" with "node=Grid_Node, category=Coal, Max Capacity=500, Units=2" + And generator "OldCoal" states "Units" of 0 from "2035-01-01" + And the model contains model "Plan" + And the model contains horizon "H1" on model "Plan" starting "2026-01-01" spanning 2 days at 24 periods per day + And the model is saved as "inputs/retires_later.xml" + When I run translate against "inputs/retires_later.xml" pipeline "plexos-to-pypsa" for model "Plan" year 2026 sink output "outputs/network.nc" + Then the PyPSA network "outputs/network.nc" generator "OldCoal" attribute "p_nom" is 1000 + And the file "outputs/extensions.json" parses as JSON with "generator.0.retirement_year" set to 2035 + And the file "decisions.md" contains "the last year the dated Units fall back to zero" + + Scenario: a generator whose units return after a dated zero is mothballed, not retired + A zero band that gives way again to the units the generator already runs takes the + plant out for those years alone, so neither end of a life is 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 "Mothball" with "node=Grid_Node, category=Coal, Max Capacity=500, Units=2" + And generator "Mothball" states "Units" of 0 from "2035-01-01" to "2040-12-31" + And the model contains model "Plan" + And the model contains horizon "H1" on model "Plan" starting "2026-01-01" spanning 2 days at 24 periods per day + And the model is saved as "inputs/mothballed.xml" + When I run translate against "inputs/mothballed.xml" pipeline "plexos-to-pypsa" for model "Plan" year 2026 sink output "outputs/network.nc" + Then the PyPSA network "outputs/network.nc" generator "Mothball" attribute "p_nom" is 1000 + And the PyPSA network "outputs/network.nc" generator "Mothball" attribute "build_year" is 0 + And the file "outputs/extensions.json" does not contain "retirement_year" + + Scenario: a generator that dates no units states neither year + 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 "Steady" with "node=Grid_Node, category=Coal, Max Capacity=500, Units=1" + And the model contains model "Plan" + And the model contains horizon "H1" on model "Plan" starting "2026-01-01" spanning 2 days at 24 periods per day + And the model is saved as "inputs/undated_units.xml" + When I run translate against "inputs/undated_units.xml" pipeline "plexos-to-pypsa" for model "Plan" year 2026 sink output "outputs/network.nc" + Then the PyPSA network "outputs/network.nc" generator "Steady" attribute "build_year" is 0 + And the file "outputs/extensions.json" does not contain "retirement_year"