ADR-0011: PyPSA optimization completeness: Store-based storage, modular capacity, retirement, slack demand, copperplate, unit-commitment extras and multi-period investment¶
- Status: accepted
- Date: 2026-10-24
Context¶
A review of PyPSA's input model against the canonical schema left a list of
things a PyPSA user configures that MEME could not express or expressed
only through native.pypsa: energy-only storage with independently sized
charger and discharger, integer-module capacity expansion, retirable
existing capacity, a penalized slack for unmet demand, a copperplate run,
the unit-commitment details beyond start and stop costs, quadratic variable
costs, a fixed state-of-charge trajectory, and investment over several
periods. Each has a canonical home already — most are existing fields that
other targets honor and PyPSA rejects or drops — and the rule of ADR-0002
says a field a target can honor should not be dropped there.
Decision¶
Each item maps onto PyPSA through the field that already means it, with the representation chosen by what the payload says, never by a PyPSA-specific switch:
- Storage as a Store. A storage technology with
storage.energy_capacitybecomes aStoreon its own bus with a charger and a dischargerLink: the energy capacity, its bounds, its expandability andinvestment_per_energy_capacitysit on the Store (e_min_pufromdepth_of_discharge,e_initialfrominitial_socof a fixed capacity,e_cyclic,standing_loss), the power capacity and its costs on the Links. Two structural constraints travel in a_storage.jsonsidecar the driver applies: equal charger and discharger power when the power capacity is a decision, andmax_charge_rate/max_discharge_rateas bounds of link power against store energy. A storage withoutenergy_capacitystays aStorageUnit;soc_profile(a fixed state-of-charge series) maps onto itsstate_of_charge_set. - Modular capacity.
capacity.per_uniton an expandable component becomes PyPSA's integer module (p_nom_mod,e_nom_mod,s_nom_mod); the unit bounds stay asp_nom_min/p_nom_max. - Retirement.
capacity.decommission.mode: continuoussplits a component into its existing part, retirable down to zero at its fixed O&M, and, when expandable, a new-build part that carries the investment cost.only_completestays rejected (a binary PyPSA has no slot for). - Slack demand.
allow_unmet_demandadds a slack generator at every load bus of the carrier, priced atpenalty_price; copperplate merges every node's bus of a carrier into one and drops the arcs, as AdOpT does. - Unit-commitment extras and costs.
operation.ramp_start_up,ramp_shut_down,standby_cost,initial_uptimeandinitial_downtime,storage.soc_profile, andcosts.<class>.variable_om_quadraticmap onto the PyPSA attributes of the same meaning. A quadratic cost together with an integer variable (committable, modular capacity) is a MIQP the free solvers cannot solve, so it is rejected unless the solver is gurobi. - Multi-period investment.
model.periodswith years become PyPSA'sinvestment_periods; snapshots become (period, timestep); every series is repeated per period;build_yearandlifetimeare emitted, and the solve runs withmulti_investment_periods. The capability ismulti_period, claimed by PyPSA; a target that does not claim it treatsperiodsas today (accepted, single horizon) with a warning.
Fields other targets cannot honor are handled as before: Calliope and AdOpT-NET0 keep every mapping they have, and the new fields they have no concept for are ignored and named in a warning, so a payload that plans on one target and on PyPSA stays one document.
Consequences¶
- Validation verdicts move on PyPSA from "rejected" to "honored" for storage rate bounds, depth of discharge, energy-capacity costs, decommissioning, unmet demand and copperplate; the validation matrix records every change.
- The Store topology adds two Links and a bus per storage; result tables report the store's energy and the links' power, and the statistics table attributes them to the technology's carrier.
- Retirement without a separate salvage value is driven by fixed O&M only; a retirement cost has no PyPSA slot and stays rejected.
- Multi-period runs multiply the snapshot index by the number of periods; time series are per horizon, not per period, until a per-period series form is needed.