Changelog¶
Here, you'll find notable changes for each version of PREP-SHOT.
Version 1.21.0 - May 7, 2026¶
PCM additions: load shedding (reliability_parameters,
--allow-load-shedding / --voll CLI flags), locational
marginal prices in the rolling-horizon output, and a switch to
long-format Parquet sidecars instead of dense NetCDF -- the
previous writer overflowed scipy's NetCDF3 32-bit vsize on
full-year Thai PCM (the dense trans_export array would have
been ~16 GB).
Why¶
Three independent needs that landed in the same release window:
Load shedding -- on big nodal models, single PCM windows are occasionally infeasible due to local water/storage budgets (cascade boundary, low-inflow hour). Without slack, the rolling driver crashes mid-year. With a VOLL-priced
lnsslack the dispatch always completes and the output reveals exactly which (hour, zone) pairs hit the floor.LMP -- the dual of the nodal power-balance constraint is the bus-level marginal price; it's the standard PCM diagnostic for congestion / fuel-mix transitions / scarcity. CEM mode already ships this; PCM didn't.
Parquet output -- pivoting the long-format result rows to dense
(hour, month, year, zone, ...)xarray arrays is wasteful when the data is sparse-by-construction (e.g. only 1230 directed lines populated out of 472 x 472 cells). On a full-year run the dense array pads NaN into ~2 B cells and blows up to ~16 GB.
Added¶
reliability_parametersblock inconfig.json:"reliability_parameters": { "allow_load_shedding": false, "voll": 10000 }
When
allow_load_sheddingis true,model.lns[h, m, y, z]enters the nodal power-balance constraint as a non-negative slack and thecost_lns = voll * sum(lns) * var_factor / weightterm is added to the objective. The slack defaults to off, so unset / pre-1.21 configs are byte-for-byte unchanged.prepshot.pcmCLI flags--allow-load-sheddingand--voll <USD/MWh>override the config block at run time.run_pcm(..., allow_load_shedding=True, voll=10000.0)programmatic kwargs.lmp.parquetin the PCM output bundle: per-(hour, month, year, zone) shadow price of the demand-balance constraint, scaled to real-year USD/MWh (raw dual timesweight / var_factor, sign-flipped). Saturates atvollfor shortage hours.PoI portability shim: PoI 0.4's
get_constraint_attribute(c, ConstraintAttribute.Dual)raisesAttributeError: Quadratric(sic) on linear constraints, so we go through the backend-native raw name (Pifor Gurobi,dualfor HiGHS) selected by a probe on the first constraint.examples/thailand_pcm/ThailandPCM.ipynbSection 10 "Result analysis" -- six diagnostic plots (annual generation by carrier, peak-week dispatch, load-shedding hotspots, top transmission corridors, hydro-station discharge profiles, LMP geography + duration curve). The notebook'sbuild-hydrocell now also writesreservoir_storage_min.csv/reservoir_storage_max.csv(from V_min / V_max in the source) and clamps initial storage into[V_min * 1.01, V_max * 0.99]so the LP isn't infeasible at hour 0 when a reservoir's V_min > 0.5 * V_max.
Changed¶
prepshot/pcm.py:_save_pcm_netcdf-- writes long-formatoutput/baseline_pcm/<var>.parquet(zstd-compressed) plus amanifest.jsoninstead of a single dense NetCDF. Total Thai-PCM full-year output goes from ~16 GB-in-memory (didn't fit) to ~40 MB on disk.prepshot/pcm.py:_extract_window_dispatch-- iteratesmodel.zone_techs[z]andmodel.out_neighbours[z]so it works against the v1.20 sparsemodel.gen/model.trans_export. Previously KeyError'd at the first inactive(z, te).prepshot/pcm.py:_extract_window_state-- guards the emptyreservoir_water_delay_timecase (int(NaN)raised when the cascade table has no rows).
Caveats¶
PoI HiGHS does not consistently expose constraint duals as a
typed attribute; the raw dual lookup is used as a fallback
and may not work on all HiGHS builds. If the dual extractor
returns nothing the PCM run logs could not extract LMP duals
and lmp.parquet is omitted -- the rest of the output is
unaffected.
Version 1.20.0 - May 7, 2026¶
Sparsity refactor: every variable and constraint family that was
indexed over the dense zone x tech or zone x zone Cartesian
now iterates only the structurally meaningful subset (real
(zone, tech) pairs from existing_fleet /
expansion_candidates, real lines from transmission_existing
/ transmission_candidates). On full-nodal Thai PCM (472 buses,
212 techs, 1230 lines) create_model drops from ~26 minutes
per window to ~0.55 s -- a ~3000x speedup for the LP-build
step alone.
Why¶
Profiling on the Thai PCM (full-nodal, 472 zones, 212 techs, 615 directed lines) showed three structurally wasteful patterns, all in the same shape:
model.add_variables(model.zone, model.tech, ...)creates ~5 Mgenvariables, ~99 % of which are 0-by-construction because each thermal/VRE plant lives at exactly one bus.model.add_variables(model.zone, model.zone, ...)creates ~10.7 Mtrans_exportvariables, ~99.5 % of which are unbounded waste because Thailand only has 1230 directed lines.Every
poi.make_tupledict(model.hour, model.month, model.year, model.zone, model.tech, rule=...)runs the rule 4.8 M times per constraint family on Thai-PCM scale, plus ~10 M times for trans-indexed families.
PoI's make_tupledict walks the dense Cartesian even when the
rule returns None (the sparse-storage path saves the result
but not the rule call), so most of the per-window time was pure
Python iteration with no LP-side payoff.
Added¶
prepshot.utils.sparse_tupledict(index_iter, rule)-- likepoi.make_tupledictbut iterates an explicit list of keys instead of the dense Cartesian, skipping the per-elementflatten_tuple+isinstanceoverhead.prepshot.load_data.compute_active_zone_tech(data_store)populatesdata_store['active_zt']/['tech_zones']/['zone_techs']/['active_zt_storage']fromexisting_fleetandexpansion_candidates. Computed once at load time so every PCM window reuses it.prepshot.load_data.compute_active_lines(data_store)populatesdata_store['active_lines']/['out_neighbours']/['in_neighbours']fromtransmission_existingandtransmission_candidates.prepshot.model.define_active_zone_tech(model)derives the per-window time-indexed key listsmodel.active_hmyzte,model.active_hmyzte_storage,model.active_hmyz1z2,model.active_yz1z2from those sets.
Changed¶
Sparse variable creation in
define_variables:gen,charge,storage,cap_newline,trans_exportnow usesparse_tupledictover the active set instead of the densemodel.add_variables(*sets).All constraint rules that were
poi.make_tupledict(..., model.zone, model.tech, ...)switched tosparse_tupledictoveractive_hmyzte/active_yzt: 11 files touched (co2.py,cost.py,dc_flow.py,demand.py,finance.py,generation.py,heat_rate.py,investment.py,transmission.py,unit_commitment.py, plus the variable-creation inmodel.py).All
for te in model.techquicksums in nodal balance rules switched tofor te in model.zone_techs.get(z, []); same forfor z1 in model.zone->for z1 in model.in_neighbours.get(z, [])/model.out_neighbours.get(z, []).prepshot._model.investment.AddInvestmentConstraintsnow pre-indexesexisting_fleetby(zone, tech)once at__init__time sotech_lifetime_ruleis O(1) per call instead of O(N) where N is the fleet size. On Thai PCM this alone saves ~0.6 s per window (the previous worst genexpr scan).prepshot.model.define_complex_setsno longer pads the densezone x zoneCartesian (was 222 784 dict writes on Thai PCM); only pads on the sparseactive_linesset, with saner defaults (transmission_line_efficiency= 1.0 lossless rather than 0 = blocked,transmission_line_lifetime= 9999 rather than 0 = expired).prepshot.pcm._build_window_params-- shallow copy (dict(full_params)) instead ofdeepcopy. The big dicts (demand,max_gen_profile,inflow) are never mutated per-window, so deep-copying them was pure waste -- ~10.9 s per window on Thai PCM, more than 20x the actualcreate_modelcost.
Performance¶
Single-window timings, Thai PCM 472-bus 212-tech 1230-line configuration, M1 Max:
Stage |
v1.19 |
v1.20 |
Speedup |
|---|---|---|---|
|
10.9 s |
~0 s |
1000x+ |
|
26+ min |
0.55 s |
~3000x |
|
-- |
0.20 s |
-- |
|
-- |
0.02 s |
-- |
Per-window total |
infeasible |
~0.8 s |
-- |
Full year (365 windows) |
infeasible |
~5-10 min |
-- |
three_zone full regression-test wall-clock: 145 s -> 117 s. Objective drift: < 0.04 % (within the 1e-2 tolerance).
Notes¶
The remaining theoretical lever is reusing the model object
across rolling-horizon windows (set_normalized_rhs /
set_normalized_coefficient for window-specific values
instead of rebuilding). Estimated extra savings: ~3 minutes
on the full year. Not in this release.
Version 1.19.1 - May 6, 2026¶
CI hotfix: turn off is_n1_secure in three_zone so the
regression test passes reliably across Python 3.9 / 3.10 / 3.11 on
GitHub Actions. The N-1 SCDC OPF feature itself is unchanged --
just no longer enabled in the regression-test scenario.
Why¶
CI runner on Python 3.10 reported solve_model returned False
after a 409-second solve. The 4 x larger N-1 LP plus continuous-UC
+ piecewise-heat-rate stack pushed HiGHS into a non-OPTIMAL
termination status (probably numerical, possibly tolerance-related)
that wasn't reproducible on the local conda environment. Killing
N-1 in the regression scenario shrinks the LP back to the v1.17
size and HiGHS solves cleanly.
Changed¶
examples/three_zone/config.json:dc_parameters.is_n1_secure : true -> false.prepshot/_model/head_iteration.py: whenmodel.optimize()returns a non-OPTIMAL status, log the actualTerminationStatusenum atWARNINGlevel before returningFalse. Future CI failures will surface the specific status code instead of the opaque "solve_model returned False" assertion.tests/test_regression.pyEXPECTED_OBJECTIVEre-baselined to1.9007200040e11(v1.17-shape LP without N-1, drops the +11 % N-1 premium that was in the v1.18/v1.19 baselines).
Notes¶
The N-1 feature still ships and still works -- enable it per
scenario by flipping is_n1_secure: true in that scenario's
config.json. examples/southeast_asia and
examples/thailand_pcm ship with N-1 contingency CSVs in place
for opt-in.
Version 1.19.0 - May 6, 2026¶
New feature: optional piecewise-linear heat rates for thermal
generators. Replaces the flat fuel_price * gen fuel-cost model
with a convex 3-segment (or N-segment) curve where the marginal
heat rate increases as a unit's output approaches its rated
capacity. LP-stable because the segments are sized so that
multipliers are non-decreasing -- the LP optimum naturally fills
cheaper segments first without binary ordering constraints.
Closes Tier 1 item 4 (generator economics granularity) of the PCM-gap analysis. Real heat-rate curves below the design point (part-load losses dominate, curve becomes concave) are still abstracted into the v1.15 UC overlay's no-load + min-stable-load constraints; this commit handles the above-design "increasing marginal heat rate" half cleanly.
Added¶
prepshot/_model/heat_rate.py--AddHeatRateConstraintsclass. Per(h, m, y, z, te)in the heat-rate set, three new constraint families:sum-to-gen:
sum_s gen_segment[..., s] = gen[..., te]per-segment width:
gen_segment[..., s] <= (frac_max[s] - frac_max[s-1]) * cap_existing[y,z,te] * dtunused-segment zero: lock
gen_segment[..., s] = 0for segments not declared for the tech.
And a new variable
model.gen_segment(lb=0) sized over(hour, month, year, zone, tech, segment_idx).add_heat_rate_fuel_cost(model)(in the same module) returns the per-segment fuel cost contribution as an ExprBuilder:fuel_price[te, y] * sum_s multiplier[s] * gen_segment[..., s]
NPV-discounted via
var_factor[y, z]and divided byweight-- identical accounting to the rest ofcost_var. Wired intocost.var_cost_rule.cost.fuel_cost_breakdownnow SKIPS techs that have a heat-rate curve (returns a zero ExprBuilder for them). Those techs are priced throughadd_heat_rate_fuel_costinstead. Without the skip, the flat-rate cost would be double-counted.New optional input
tech_heat_rate.csv(table format) with columnstech, segment, frac_max, multiplier. One row per (tech, segment); per-tech segments must be sorted byfrac_maxascending and have non-decreasing multipliers (the loader validates this and raisesValueErrorif violated).New
cost_parameters.is_piecewise_heat_rateconfig flag (default false). When false, the module is a no-op and PREP-SHOT keeps the v1.18 flat-rate behaviour byte-for-byte.
Default heat-rate curves shipped¶
3-segment convex curve for every thermal tech in every example:
Segment 1 (0-50 % of cap): multiplier 1.00 (baseline) Segment 2 (50-80 % of cap): multiplier 1.03 Segment 3 (80-100 % of cap): multiplier 1.10
Eligible carriers: coal, oil, gas, bioenergy, biomass, nuclear,
geothermal. Solar / wind / hydro / storage are not thermal;
tech_heat_rate.csv skips them and they keep the flat-rate
fuel cost (which is 0 for renewables anyway).
Per-example wiring¶
three_zone:is_piecewise_heat_rate=true. 1 thermal tech (Coal) x 3 segments = 3 rows in tech_heat_rate.csv.thailand:is_piecewise_heat_rate=false. CSV ships (3 thermal techs x 3 segments = 9 rows) for opt-in.southeast_asia:is_piecewise_heat_rate=false. CSV ships (4 thermal techs x 3 segments = 12 rows) for opt-in.
Regression¶
three_zone EXPECTED_OBJECTIVE 2.1091201892e11 -> 2.1092168430e11 (+0.005 %). The drift is tiny because Coal in this scenario rarely runs above 50 % of cap (segment 1, multiplier 1.00) -- wind, solar, and hydro dominate dispatch. The constraint structure is in place for scenarios where thermal pushes the upper segments; in those cases the cost shift is meaningful.
Version 1.18.0 - May 6, 2026¶
New feature: optional N-1 security-constrained DC OPF at the
zone level. Layered on top of the v1.13 DC flow module: when
enabled, the same dispatch must be feasible in the base case AND
under each line outage listed in
transmission_contingencies.csv (preventive policy -- gen
and charge are shared across base + contingencies, only flows
redistribute). Closes Tier 1 item 1 (network fidelity) of the
PCM-gap analysis.
Added¶
prepshot/_model/dc_flow.pyextended with four new rule methods and three new variable sets, all gated bydc_parameters.is_n1_secure:model.theta_c[h, m, y, z, c]-- phase angle in contingencyc.model.trans_export_c[h, m, y, z1, z2, c]-- per-direction flow in contingencyc.theta_ref_c_rule-- pin reference zone's angle to 0 per contingency.flow_c_rule-- DC flow eq for every (line, contingency) pair; the outaged line itself is forced to zero in both directions.cap_c_rule-- per-direction capacity bound (numerically same as the base case).demand_balance_c_rule-- per-(zone, contingency) power balance using contingency-case flows but the SHARED base-casegenandcharge.
New optional input
transmission_contingencies.csv(colszone1, zone2) listing the lines to protect against. Empty file or missing -> no contingencies, equivalent tois_n1_secure=false.New config flag
dc_parameters.is_n1_secure(defaultfalse). Requiresis_dc_flow=true; falsey configs preserve byte-for- byte v1.17 behaviour.Per-example wiring:
three_zone:is_n1_secure=true; all 3 lines protected. Three-fold parallel constraint set on top of the base case.thailand:is_n1_secure=false(single zone, no inter-zone lines).southeast_asia:is_n1_secure=false; CSV ships listing the 7 inter-country lines for opt-in. The 8x problem-size multiplier on a 288-hour x 5-zone scenario isn't currently practical for routine runs.
Implementation notes¶
Preventive policy chosen over corrective:
genis one decision feasible everywhere, no post-contingency redispatch. Stays LP, simpler to formulate, and mirrors what most planning studies report. Corrective (with ramp-limited redispatch) is a v1.19+ candidate.The contingency-case flows are bounded by the SAME
cap_lines_existingas the base case. A real SCUC would often increase emergency ratings; the current model treats thermal limits as identical, which is conservative.No additional cost terms -- the security constraint is purely feasibility-side.
Changed¶
tests/test_regression.pybaseline re-captured for three_zone with N-1 enabled:1.9009490431e11->2.1091201892e11(~+11 %). The constraint is genuinely binding -- v1.17's optimum leaned on inter-zone transmission more than any single line could survive outaging.
Verified¶
three_zone CEM solves with full feature stack (UC continuous + 4-product reserves + DC flow + N-1) in roughly the same time as v1.17 (~5-10 min for 3 head iterations).
Version 1.17.0 - May 6, 2026¶
Closes the v1.14.1 limitation: PCM rolling-horizon now works with
cascading hydro and the full v1.15 / v1.16 feature stack (UC,
multi-product reserves, DC flow). Adds a cross-window cascade-state
mechanism that carries upstream outflow from one window's solve into
the next window's hydro inflow expression, instead of dropping the
term at boundaries (which made downstream stations infeasible
whenever their min_outflow exceeded their incremental natural
inflow).
Added¶
New
params['prior_outflow']lookup, dict-keyed by(station, hour, month, year) -> m**3/s. Populated byprepshot.pcm._extract_window_state: at the end of each committed window, the lastmax_delayhours ofmodel.outflowfor every upstream station are stashed and threaded into the next window's params.prepshot._model.hydro.inflow_rulenow consultsparams['prior_outflow']whent = h - delay < hour[0]. The numeric outflow is added as a constant term in the inflow expression, so the LP sees the cascade contribution exactly as it would in a single-pass full-horizon CEM solve.Three-tier fallback:
CEM (
cyclic_hydro=True): wrap modularly within the window.PCM with
prior_outflow(v1.17+): use the carried numeric.PCM without
prior_outflow(first window): drop the term -- the v1.14.1 fallback, accurate only for the very first window.
Changed¶
Default reserve eligibility for hydro carriers: now eligible for
non_spinning(in addition to the existingregulation_up,regulation_down,spinning). Real ancillary-services markets typically allow hydro to qualify for non-spinning since hydro can ramp from cold within the standard 10-minute non-spinning window. Without this, zones with hydro-only fleets (likethree_zone's BA2 in the CEM-2025 buildout) had no supplier fornon_spinningand tripped a presolve infeasibility under PCM mode.tests/test_regression.pybaseline re-captured after the hydro non_spinning eligibility tweak. Drift is sub-percent.
Verified¶
PCM single-window with full feature stack (UC continuous-relax + 4-product reserves + DC flow + per-station hydro):
three_zonesolves to optimal in ~1 minute.PCM multi-window rolling with cascade (
--horizon 24 --step 12) on the same setup: 4 sequential windows all Optimal, dispatch written tobaseline_pcm.nc.PCM + UC compose:
--cap-source <CEM_baseline>+ UC continuous relaxation works.Single-window PCM at
--horizon 48 --step 48remains the minimal "fixed-capacity dispatch validator" mode.
Known limitations¶
The carbon emission cap from
policy_carbon_emission_limit.csvis still applied per-window without rescaling for window length; the naive filter applies the full-year cap to e.g. a 48-hour window. For the shipped examples it's non-binding so the dispatch is unaffected, but a window-length-rescaled cap is the correct formulation. v1.18 candidate.
Version 1.16.0 - May 6, 2026¶
Generalises the reserve module from a single up/down pair to named reserve products, matching the structure of real ancillary-services markets and the way GenX, ReEDS, and PowNet model reserves. Closes Phase D of the PCM-fidelity roadmap (Phase A = PCM mode in v1.14; Phase B = UC overlay in v1.15; Phase C = DC flow in v1.13).
Default product set¶
Four products ship by default. The direction (up vs down) is encoded
in the product name (suffix _down -> down direction):
regulation_up/regulation_down-- frequency regulation, fast governor response.spinning-- contingency reserve, online + ready, up-only.non_spinning-- contingency reserve, may be offline (in a real UC; in our LP relaxation it's just a slower-response reserve), up-only.
Adding a new product (e.g. flex_ramp_up, flex_ramp_down)
requires no code change -- just rows in the eligibility CSV.
Schema change (BREAKING)¶
tech_reserve_eligible.csv: cols(tech, eligible)->(tech, product, eligible). v1.12-v1.15 files need migration: each old row becomes one row per product the tech is eligible for.reserve_requirement_up.csv+reserve_requirement_down.csvconsolidated intoreserve_requirement.csvwith cols(zone, year, product, unit, value). Direction inferred from product name.params.jsonschema entriesreserve_requirement_up/reserve_requirement_downreplaced by a singlereserve_requiremententry.
Migration ships in-place: the three example datasets all have new CSVs in v1.16. v1.16 will not read v1.12-v1.15 reserve files.
Constraints¶
The headroom is now shared across products in the same direction (otherwise a unit's spare megawatts would be double-counted across regulation + spinning):
- for each (h, m, y, z, te) and direction d in {up, down}:
- sum_{p in products_d} reserve[h,m,y,z,t,p] + (gen if d=up else
-gen + cap*p_min*dt) <= cap * p_max * dt
Per-product zonal requirement:
- for each (h, m, y, z, p):
sum_t reserve[h,m,y,z,t,p] >= REQ[z,y,p] * dt
Output¶
The two reserve_up / reserve_down xarray DataArrays are
replaced by a single reserve array dimensioned
(hour, month, year, zone, tech, product). Slice on product
to recover the v1.12-style per-direction view.
Eligibility defaults shipped¶
By carrier (in tech_registry.csv):
coal / oil / gas / bioenergy / biomass: all 4 products.
nuclear: spinning + non_spinning only (slow ramp).
geothermal: regulation_up + spinning + non_spinning.
hydro (including cascading per-station): regulation_up, regulation_down, spinning. Excludes non_spinning (hydro can't black-start as quickly as a peaker).
solar / wind / storage discharge: not eligible (storage will get proper regulation eligibility in v1.17 once the UC overlay understands charging-mode reserve).
Default requirement values¶
The single value from reserve_requirement_up.csv (v1.15) is
split across products as: regulation_up 10 %, regulation_down 10 %,
spinning 30 %, non_spinning 50 %. So the total reserve commitment
stays the same as v1.15 -- this commit is a generalisation of
structure, not a tightening of policy.
Regression¶
three_zone EXPECTED_OBJECTIVE 1.9070270274e11 ->
1.9070043702e11 (~0.001 % drift -- numerically identical at this
tolerance, since total reserve is unchanged).
Version 1.15.0 - May 6, 2026¶
New feature: optional unit commitment (UC) overlay using a
clustered MILP formulation. Closes the LP-vs-PCM fidelity gap on the
thermal-fleet side: when enabled, each UC-eligible tech (typically
coal, gas, oil, biomass) is treated as a cluster of identical units
of size tech_unit_size[te]. The model now decides not just how
much to dispatch but how many units to start, run, and shut down
each hour, with realistic minimum up/down times and startup costs.
This is the Phase B milestone of the PCM-fidelity roadmap (after v1.13 DC flow and v1.14 PCM mode). With UC enabled, PREP-SHOT becomes a MILP (HiGHS handles it; runtime grows several-fold).
Added¶
prepshot/_model/unit_commitment.py--AddUnitCommitment Constraintsclass with three new decision variables per(h, m, y, z, te):online[h]-- number of units in the cluster online (integer in[0, N_units]whereN_units = cap_existing / unit_size).startup[h]-- units started this hour.shutdown[h]-- units shut down this hour.
And six new constraint types:
N-units bound:
online[h] <= N_unitsState evolution:
online[h] - online[h-1] = startup[h] - shutdown[h](skipped ath == hour[0]; first-hour state free)Dispatch upper / lower bound on online units:
gen[h] <=/>= online[h] * unit_size * p_max_pu (or p_min_pu) * dtMin up time:
online[h] >= sum_{i=0..MinUp-1} startup[h-i]Min down time:
(N_units - online[h]) >= sum_{i=0..MinDown-1} shutdown[h-i]
New cost terms wired into
cost.pyviaadd_uc_cost_terms: startup cost ($/MWper startup) and no-load cost ($/MW-hwhile online), both NPV-discounted viavar_factor[y, z]and divided byweight.Six new optional inputs in every shipped example, all keyed by tech (file -> column):
tech_uc_eligible.csv->eligible(boolean)tech_unit_size.csv->value(MW)tech_min_up_time.csv->value(hours)tech_min_down_time.csv->value(hours)tech_startup_cost.csv->value($/MW)tech_no_load_cost.csv->value($/MW-h)
Defaults are ballpark NREL ATB / PowNet values: Coal 250 MW units, 8h up, 8h down, $150/MW startup, $3/MW-h no-load; Gas 150 MW units, 2h/1h, $100/MW, $5/MW-h; Oil 50 MW, 2h/2h, $80/MW, $8/MW-h; Bioenergy/Biomass 30 MW, 4h/4h, $70/MW, $4/MW-h; Nuclear 1000 MW, 24h/48h, $500/MW, $2/MW-h. Edit the CSVs to fine-tune per scenario.
New
uc_parametersblock in each example'sconfig.json:three_zone:is_uc=true,uc_relaxation="continuous". The full integer MILP on three_zone with the default 3-pass head iteration takes 15-30 minutes -- too slow for the regression test, so the example ships with the LP relaxation (online / startup / shutdown become continuous in their natural ranges). Flipuc_relaxationto"integer"for genuine MILP runs.thailand:is_uc=false. Single-zone full-year LP is already heavy.southeast_asia:is_uc=false. Multi-zone, multi-station hydro -- MILP would push runtime past acceptable smoke-test bounds.
Set
uc_relaxation="continuous"to fall back to the LP relaxation (binaries become continuous in their natural ranges) -- useful for warm-starts and scaling tests.prepshot.load_data.extract_config_datanow returnsis_ucanduc_relaxationso downstream modules can branch on them. Missing block -> defaults off, pre-1.15config.jsonfiles are byte-compatible.
Changed¶
tests/test_regression.pyEXPECTED_OBJECTIVEre-baselined forthree_zonewith UC enabled. UC adds startup + no-load costs and may shift dispatch to avoid frequent cycling, raising total NPV cost. Inline drift history extends to v1.15.0.
Performance notes¶
MILP solve on
three_zone(48 hours, 3 zones, 4 thermal techs): a few seconds.Full-year (8760 hour) MILP at scenario sizes like
thailand: not tractable directly -- pair with PCM rolling-horizon mode (prepshot.pcm) which solves UC on overlapping windows.Set
uc_relaxation="continuous"to keep the model LP for quick experimentation; the integer constraint drops, but the constraint topology and cost terms stay in place.
Version 1.14.1 - May 5, 2026¶
Incremental fixes on top of the v1.14.0 PCM scaffold. Adds a non-cyclic hydro mode and battery SOC carryover. Multi-window rolling now solves on scenarios without binding min-outflow on cascaded hydro -- the cross-window cascade-state issue moves to v1.15+.
Added¶
New
cyclic_hydroparam flag (defaultTruefor CEM compatibility). WhenFalse(set automatically byprepshot.pcm._build_window_params),hydro.inflow_ruledrops the upstream-cascade contribution at hours whereh - delay < hour[0]-- instead of wrapping modularly into the same window. This makes each PCM window stand alone in time, rather than implicitly looping its end into its beginning.Battery SOC carryover in
prepshot.pcm._extract_window_state: read eachmodel.storage[terminal_hour, m, y, z, te](in MWh), divide bycap_existing[y,z,te] * energy_to_power_ratio[te] * dtto recover the per-unit-of-cap fraction thatinitial_energy_storage_levelexpects, clamp to[0, 1], and pass into the next window's params. Storage techs with zero capacity are skipped (the next window's lookup defaults to 0).
Known limitations¶
Cascading hydro across windows: at window boundaries, downstream stations see only their natural (incremental) inflow because the upstream's outflow during the lookback period lives in the previous window's solve and isn't carried forward. When downstream
reservoir_outflow_min > natural_inflow, water balance drains storage belowstorage_minand the sub-problem is infeasible. Workaround: run--horizon == --step == period_length(single-window). Permanent fix in v1.15+ via a cross-window cascade-state vector.Annual carbon cap not rescaled to window length: the naive filter applies the full-year cap to each window. Not binding for the shipped examples but wrong on principle; will rescale by
window_hours / hours_in_yearin v1.15.
Version 1.14.0 - May 5, 2026¶
New mode: production-cost-model (PCM) with a rolling-horizon
driver. Companion to the default capacity-expansion (CEM) flow in
run.py: PCM takes a fixed fleet (from a prior CEM baseline.nc
or a user-supplied capacity CSV) and solves only hourly dispatch
over a chosen year, in windows. PowNet- and PyPSA-style.
This is the Phase A scaffold of the PCM-fidelity roadmap (Phase B = unit commitment overlay, Phase C = DC flow which already landed in v1.13). It's shipped as alpha -- single-window mode works end-to-end; multi-window rolling has a known cyclic-wrap interaction documented in the module docstring and tracked for v1.14.1.
Added¶
prepshot/pcm.py-- new module + CLI entry point:python -m prepshot.pcm <scenario_dir> --year 2025 \ --horizon 48 --step 48 [--cap-source baseline.nc]
Supports both
.nc(CEM result) and.csvcapacity sources. Output lands inoutput/baseline_pcm.ncso it doesn't collide with CEM'sbaseline.nc.New
skip_end_storageparam flag plumbed throughhydro.end_storage_ruleandstorage.end_energy_storage_rule; whenTrue, the cyclical "terminal storage = initial storage" equality is dropped so PCM windows can have free terminal SOC (carried into the next window).prepshot.load_data.extract_config_datanow also returnshours_in_yearso PCM windows can recompute the cost-objectiveweightfor shorter time slices.
Changed¶
model.hour_pnow derives fromhour[0]([hour[0] - 1] + hour) instead of being hardcoded to[0] + hour. CEM behaviour is unchanged because CEM useshour=[1..N]; PCM windows starting athour[0] > 1get the correct prior-hour anchor for storage balances.storage.init_energy_storage_ruleandstorage.end_energy_storage_rulereferencemodel.hour_p[0]instead of literal0for the prior-hour storage variable.generation.ramping_up_rule/ramping_down_ruleskip the inter-hour delta whenh == model.hour[0](noh - 1in the set). Was1 < h, nowh > model.hour[0].hydro.inflow_rulerewrites the cyclic-wrap arithmetic to use modular indexing relative tohour[0],hour[-1]instead of the implicit[1..24]assumption. CEM behaviour is mathematically identical forhour=[1..N].
Known limitations (v1.14.0 alpha)¶
Multi-window rolling is unstable. The hydro module's cyclic
inflow_rulecouplesupstream.outflow[hour[-1]]todownstream.inflow[hour[0]]within each window; with carry-over state athour[0]-1, the second window's cyclic loop can have no feasible point. Workaround: run with--horizon == --step == period_length(single-window PCM = fixed-capacity CEM dispatch). v1.14.1 will switch hydro to a non-cyclic rolling form.Battery state carryover deferred.
initial_energy_storage_ levelis per-unit-of-cap, not absolute MWh; an absolute-MWh state extracted from a solved window doesn't compose with that convention. Each window currently re-initialises batteries to the dataset's default SOC. Fix in v1.14.1.
Version 1.13.0 - May 5, 2026¶
New feature: optional DC linearised power flow for inter-zone
transmission. Layered on top of the existing transport-model
trans_export variables -- the LP capacity bound stays in place;
DC flow adds Kirchhoff's voltage law via phase-angle differences.
First step toward production-cost-model fidelity.
Added¶
prepshot/_model/dc_flow.py--AddDCFlowConstraintsclass.theta variable:
model.theta[h, m, y, z], free in[-pi, pi]. Created only when the module is enabled.reference bus:
theta[h, m, y, ref_zone] = 0per timestep, pinning the otherwise translation-invariant solution.flow equation: for every unordered zone pair
(z1, z2)with a positive susceptanceb,\[\begin{split}\\text{trans\\_export}_{z_1,z_2} - \\text{trans\\_export}_{z_2,z_1} = b \\cdot (\\theta_{z_1} - \\theta_{z_2}) \\cdot \\Delta h\end{split}\]Each pair gets ONE constraint (alphabetical ordering avoids duplicates). Pairs with
b = 0-- electrically disconnected -- are skipped, so the constraint structure matches the network topology.stays LP: no binaries, no non-convexity. Adds
|hour| x |month| x |year|reference-bus equalities plus|hour| x |month| x |year| x N_pairsflow equalities.
New optional input file in every shipped example:
transmission_susceptance.csv(colszone1, zone2, unit, value): per-pair susceptance in MW/rad. Defaults derived from line capacity:b = max(2 * existing_capacity_MW, 1000), so a ~0.5 rad angle difference saturates the line. Override per pair to fine-tune.
New
dc_parametersblock in each example'sconfig.json:three_zone:is_dc_flow=true,reference_zone=BA1.thailand:is_dc_flow=false(single-zone, nothing to constrain).southeast_asia:is_dc_flow=true,reference_zone=Thailand.
prepshot/load_data.pyreads the new config block (defaultis_dc_flow=falseif the section is missing -- pre-1.13config.jsonfiles are byte-compatible).
Changed¶
tests/test_regression.py:EXPECTED_OBJECTIVEfor thethree_zoneregression bumped to1.8967979487e11. Drift history kept inline for traceability:v1.1.1 ->
1.8793771299e11(transport, no reserve)v1.12.0 ->
1.8878269786e11(+ reserve up/down)v1.13.0 ->
1.8967979487e11(+ DC flow Kirchhoff)
Each step is a real model upgrade, not solver drift; the regression test catches accidental cost regressions, and the layered baselines let future readers see what each feature added.
Version 1.12.0 - May 5, 2026¶
New feature: operating-reserve constraints (LP relaxation, no unit commitment). Two reserve directions are tracked -- up (capacity available to ramp dispatch up if demand spikes / a unit trips) and down (capacity available to ramp down if VRE over-generates or load drops). Each plant's headroom above and below its current dispatch counts toward the corresponding zonal requirement; non-eligible techs (typically solar / wind / storage discharge) are forced to zero in both directions. The relaxation matches GenX's "no-UC" mode and is acceptable for capacity-expansion planning, not for sub-hourly dispatch studies.
Added¶
prepshot/_model/reserve.py-- new constraint classAddReserveConstraintswith four rules per timestep:headroom up:
reserve_up[h,m,y,z,t] <= cap_existing * p_max_pu * dt - gen[h,m,y,z,t]for eligible techs (forced to 0 otherwise).headroom down:
reserve_down[h,m,y,z,t] <= gen[h,m,y,z,t] - cap_existing * p_min_pu * dtfor eligible techs (forced to 0 otherwise).requirement up / down:
sum_t reserve_<dir>[h,m,y,z,t] >= reserve_requirement_<dir>[z,y] * dtper (zone, year, hour).
model.reserve_upandmodel.reserve_downdecision variables (hour x month x year x zone x tech), both gated byconfig.reserve_parameters.is_reserve. When the flag is missing orfalsethe variables + constraints are not built, so any pre-1.12config.jsonis byte-compatible.New optional inputs in every shipped example:
tech_reserve_eligible.csv(colstech, eligible): dispatchable carriers (coal, gas, oil, bioenergy, hydro, nuclear, geothermal) default to1; solar / wind / storage default to0. A single eligibility flag covers both directions -- thermal plants and dispatchable hydro can ramp either way; storage and VRE can't.reserve_requirement_up.csvandreserve_requirement_down.csv(colszone, year, unit, value): per-zone-year reserve in MW. Both default to the same placeholder per example -- 100 MW forthree_zone, 500 MW per zone forsoutheast_asia, 1500 MW forthailand(~5 % of Thailand 2023 peak). Override either file independently to tune the directions.
New
reserve_parametersblock in each example'sconfig.json. Set to"is_reserve": trueforthree_zoneandthailand(~4 minutes per solve, acceptable). Set to"is_reserve": falseforsoutheast_asiabecause the up+down constraints over 288 hours x 5 zones x 65 techs (mostly per-station hydro) push HiGHS to 25+ minutes -- too slow for an example dataset. The CSVs are still shipped so flipping the flag works out of the box.
Changed¶
tests/test_regression.py:EXPECTED_OBJECTIVEfor thethree_zoneregression bumped from1.8793771299e11(v1.1.1 baseline) to1.8874579528e11. The reserve constraints (up + down together) force some dispatched headroom above and below the operating point on eligible techs, which raises total NPV cost by ~0.4 %.
Fixed¶
examples/southeast_asia/input/reservoir_{initial,final}_storage_ level.csv: relaxed from= storage_maxto= 0.5 * storage_maxfor all 57 stations. The previous setting forced each reservoir to start AND end the year at full capacity, leaving zero swing room. HiGHS sometimes accepted the tight feasible region (commit06e1286was such a case), but presolve flagged it as infeasible on most runs and reliably so once any new constraint was layered on top -- including the reserve module added in this release. Half-full is also a more realistic annual-average operating point for these reservoirs.
Version 1.11.1 - May 5, 2026¶
Documentation infrastructure: docs are now hosted on Read the Docs
at https://prep-shot.readthedocs.io/, with a Chinese (zh_CN)
translation in flight. Also drops the offline-PDF build (the
canonical docs are HTML only).
Added¶
.readthedocs.yaml-- the build config RTD requires. ubuntu-22.04 + python 3.11, pandoc apt package (for nbsphinx), andpip install -e .so autodoc can importprepshot.html_theme_optionsported fromgodotengine/godot-docs:flyout_display: "attached"puts the version + language picker inline at the bottom of the sidebar (Godot's UX) once the RTD Addons framework is enabled for the project.sphinxcontrib-mermaidextension + an architecture diagram on the landing page.In-tree Chinese (
zh_CN) translation scaffolding underdoc/source/locale/zh_CN/LC_MESSAGES/-- 11 pages translated: index, Installation, Glossary, Model_input_output, Contribution, and the six how-to recipes (~447 strings total)."Translating the Documentation" section in
Contribution.rstwith the extract / update / translate / build workflow.
Changed¶
All "Official Documentation" / "Tutorial Page" links in the README,
index.rst, andInstallation.rstrepointed from the GitHub-Pages URL tohttps://prep-shot.readthedocs.io/.The mermaid architecture diagram on the landing page rewritten to use Mermaid's quoted-label
\\nsyntax instead of HTML<br/>, sidestepping a Sphinx HTML-escape bug that double- escaped the directive contents.
Removed¶
The
Generate offline PDFstep from.github/workflows/static.ymland thetexlive-latex-recommended/texlive-xetex/latexmk/xindyapt installs that fed it.latex_engineandlatex_documentslikewise dropped fromconf.py. Offline reading is now via the htmlzip download from RTD.Empty
.gitattributesand the easter-eggdownwasherglossary entry.
Notes¶
This is the first release with ``.readthedocs.yaml`` shipped.
Earlier tags (v1.11.0 and below) cannot build on RTD because
RTD now requires the YAML at the project root. Activate latest
+ v1.11.1 (and future tags) in RTD admin's Versions tab; leave
older tags inactive. They remain accessible as GitHub release
tarballs.
Version 1.11.0 - May 5, 2026¶
Documentation polish: better navigation, more reference content, no new features.
Added¶
doc/source/Glossary.rst-- a reference for the energy-modeling, hydropower, and optimization terminology used throughout the docs (~50 entries: capacity expansion, head iteration, LMP, NPV, carrier, cascading hydropower, WACC, ...).doc/source/how_to/-- a new "How-To Recipes" section with five short, focused walkthroughs for common tasks:add_a_technology-- introduce a new generation tech via CSV edits.tighten_a_carbon_cap-- run a counterfactual without overwriting the baseline.compare_two_scenarios-- side-by-side analysis with xarray.inspect_lmps-- read the v1.9.1shadow_price_demandoutput.add_a_cascading_hydropower_system-- introduce a multi- station cascade with reservoir physics.
Architecture diagram on the landing page (
index.rst), drawn with the newsphinxcontrib-mermaidextension. Shows the data flow from scenario directory throughload_data/create_model/solve_model/extract_resultsand back out as NetCDF."Edit on GitHub" link in the top-right of every page (via
html_contextinconf.py) so readers can fix typos and rephrase paragraphs without cloning.Solver-choice tabs (
HiGHS/Gurobi/COPT/MOSEK) inInstallation.rst, leaning on the existingsphinx_tabsextension.Quick-link bar on the landing page above the Overview section (Get started / Install / How-to / Inputs / GitHub).
Changed¶
The sidebar is now grouped into four sections via separate
toctreedirectives: Getting Started (Installation, Quickstart), User Guide (Model Inputs/Outputs, Math notation, Glossary, How-to recipes), Reference (API, Stability, Changelog), Community (Forum, Contribution, Citations, References). Replaces the previous flat 10-page list.The Quickstart now includes a "Scenario background" section (3 BAs, 15 hydropower stations, 2020-2030 zero-carbon pathway) -- previously a separate Tutorial page.
Removed¶
doc/source/Tutorial.rst-- a 47-line page that only contained scenario context and a redirect to Quickstart. Its content moved into the Quickstart's intro.Duplicate "Run an example" / "Run your own model" instructions from
Installation.rst-- now a 1-line pointer to Quickstart."Input formats" and "Migrating an existing input directory" sections from
Installation.rst-- redundant withModel_input_output.rst.
Version 1.10.0 - May 5, 2026¶
Repository housekeeping. Each shipped scenario is now self-contained
under examples/ with its own config.json + params.json +
input/. There is no longer a "default" scenario at the repo
root -- users explicitly pick one with cd examples/<scenario>.
Added¶
examples/thailand/-- the legacysingle_node_with_hydro/dataset migrated from v1.4 wide-format Excel to the v1.9 long-CSV schema. 13 cascading Mekong-basin reservoirs (Bhumibol, Sirikit, Srinagarind, ...) modeled per-station;Large Hydropowercapacity allocated byN_max.main.ipynbrewritten asThailand.ipynbwith the v1.9 API.examples/three_zone/{config.json, params.json, input/}-- the canonical 3-zone synthetic dataset (used by Quickstart and the regression test) now self-contained.examples/southeast_asia/{config.json, params.json, input/, SoutheastAsia.ipynb}-- the Lower Mekong scenario also self-contained, with its own dataset directory.
Changed¶
run.pynow takes an optional positional scenario-directory argument (defaults tocwd). Without args, expects to be run from inside anexamples/<scenario>/directory.tests/test_regression.pycd``s into ``examples/three_zone/for the duration of the solve.prepshot/logs.py-- fixed mkdir bug (was creatinglog/'s parent, notlog/itself); the directory is now auto-created on first run rather than tracked in git.examples/single_node_with_hydro/->examples/thailand/(consistent geography-based naming withsoutheast_asia/).
Removed¶
Repo-root
config.jsonandparams.json(no default scenario).Repo-root
log/andoutput/directories (now ignored as runtime artifacts).binder/(Binder launcher config) -- cold start was too slow. Colab remains as the online launcher.toolkit/-- merged intotools/(single namespace for one-off scripts).Empty
.gitattributes.
Version 1.9.1 - May 3, 2026¶
Added¶
New output variable
shadow_price_demand-- the dual of the nodal power-balance constraint, exposing the locational marginal price (LMP) at each(hour, month, year, zone). Sign is flipped from the raw dual so positive values mean "more expensive to serve more demand", matching the convention used by PyPSA / Switch / GenX. Discounted to NPV; divide byvar_factor[year, zone]to recover undiscounted real-year prices.prepshot/output_data.py::create_data_arraygained anextractorparameter so the same helper can lift either primal values (model.get_value, the default) or duals (model.get_constraint_dual) out of a tupledict.
Notes¶
The shadow-price extraction is wrapped in a try/except: if the solver does not return duals (e.g. for a MIP solve, or after an infeasible run), a warning is logged and the variable is omitted from the NetCDF file rather than aborting the run.
Version 1.9.0 - May 3, 2026¶
Optional finance module: weighted-average cost of capital (WACC) for
new-build investment, plus public-debt accounting and caps.
Backported from the dev branch (commit bfd9de6, "add finance
module") and adapted to the v1.8.x long-format / per-zone /
max-naming conventions. The feature is OFF by default; the
regression objective is unchanged at 1.880e+11 for the canonical
input/ dataset.
Added¶
prepshot/_model/finance.pywithAddFinanceConstraints:public_debt_newtech[y, z, te]-- discounted public-debt obligation incurred by each new-tech investment, exported in theyear.ncresults.System-wide cap:
sum over (z, te) of public_debt_newtech[y, z, te] <= public_debt_max_system[y]. Skipped when missing or+inf.Per-zone cap:
sum over te of public_debt_newtech[y, z, te] <= public_debt_max_zone[z, y]. Same skip behavior.
prepshot/utils.py::calc_interest_rate-- weighted-average cost of capital from public-debt / private-debt / equity tranches.Seven new optional inputs (all
required: false):finance_public_debt_ratio.csv(per-tech),finance_private_debt_ratio.csv(per-tech),finance_cost_of_public_debt.csv(per-tech, per-zone),finance_cost_of_private_debt.csv(per-tech, per-zone),finance_cost_of_private_equity.csv(per-tech, per-zone),finance_public_debt_max_system.csv(per-year),finance_public_debt_max_zone.csv(per-zone, per-year).
Changed¶
prepshot/load_data.py::compute_cost_factors-- when finance inputs are present,inv_factor[tech, year, zone]discounts the construction outlay at the project-level WACC instead of the zonal discount rate. Fixed/variable/transmission factors continue to use the zonal discount rate. With finance OFF (no inputs),WACC == discount_rateand the legacy behavior is preserved.prepshot/model.py::create_model--AddFinanceConstraintsis wired in only whenparams['public_debt_ratio']is populated, so users without finance inputs see no extra variables, constraints, or output variables.prepshot/output_data.py::extract_results_non_hydro-- emitspublic_debt_newtechonly when finance is enabled.
Notes¶
The dev-branch commit used wide-Excel inputs and
np.Inf; this backport uses long-format CSV,np.inf, and the v1.8.0max/minnaming convention (e.g.public_debt_max_systemrather thanpublic_debt_upper_bound_system).Cap rules treat both missing entries and
+infas "no constraint", matching the candidates / carbon-emission-limit conventions elsewhere in the model.
Version 1.8.1 - May 3, 2026¶
Fixed¶
prepshot/_model/investment.py: retirement check now looks uplifetimeat the commissioning year, not the current modeled year, so units built at vintagecyretire aftercy + lifetime[te, cy]regardless of any later parameter changes. Bug present in two locations:tech_lifetime_rule(existing-fleet retirement, introduced by the v1.7.0 refactor):lifetime[te, y]->lifetime[te, cy].remaining_capacity_rule(new-build retirement, original PR #47 by Quan YUAN):lt[te, y]->lt[te, yy].
The shipped
input/tech_lifetime.csvhas constant lifetime across years for every tech, so the regression objective is unchanged at1.880e+11. The bug only manifests when users supply time-varying lifetimes (e.g. modeling tech improvement).
Version 1.8.0 - May 3, 2026¶
PyPSA-style data model. The fixed resource_type enum is replaced
with a free-form carrier string plus boolean per-tech behavior
flags; any tech can now bound its dispatch with time-varying
p_max_pu / p_min_pu profiles, removing the need for separate
capacity_factor and must_run paths. Hydro plants are first-class
techs (carrier hydro) instead of an aggregate Hydro. Input
files gain consistent domain prefixes (tech_, reservoir_,
transmission_, storage_, policy_, economic_) and
adopt max / min instead of mixed upper_bound / lower_bound.
Existing capacity and candidates are now structured symmetrically for
both plants and transmission lines.
The regression test confirms the model objective is unchanged at
1.880e+11 for the canonical input/ dataset across all
intermediate refactors.
Added¶
input/tech_registry.csv(wastechnologies.csv) -- per-tech registry with columnstech,name,carrier,is_storage. Replaces the rigidresource_typeenum.input/tech_max_gen_profile.csvandinput/tech_min_gen_profile.csv-- optional time-varying upper / lower bounds on dispatch (PyPSA'sp_max_pu/p_min_pu). Subsumes thecapacity_factorandmust_runpaths.input/transmission_candidates.csv-- symmetric counterpart totech_candidates.csvfor new transmission lines, with columnszone1, zone2, year, unit, capacity_min, capacity_max.input/transmission_existing.csv-- now keyed by(zone1, zone2, commission_year)and respectstransmission_lifetime, matching the existing-fleet structure for plants.Per-zone discount factor:
input/economic_discount_factor.csvis keyed by(zone, year); cost factors propagate throughcost.py.Custom carbon-emission-limit regions in
input/policy_carbon_emission_limit.csv: each row carries a comma-separatedzonesfield, allowing arbitrary multi-zone caps without per-zone duplication.
Removed¶
prepshot/_model/nondispatchable.py-- subsumed by the unifiedgen_up_bound_rule/gen_low_bound_ruleinprepshot/_model/generation.py.predefined_hydropower.csvand themust_runelse-branch inhydro.py-- replaced bytech_min_gen_profile.csv.capacity_factor.csv-- replaced bytech_max_gen_profile.csv.
Changed¶
Naming sweep: all
upper_bound/lower_boundfiles, param keys, and DataFrame columns renamed tomax/min:tech_upper_bound.csv->tech_capacity_max.csvtech_lower_bound.csv->tech_capacity_min.csvreservoir_storage_upper_bound.csv->reservoir_storage_max.csvreservoir_storage_lower_bound.csv->reservoir_storage_min.csvtech_candidates.csvcolumnslower_bound/upper_bound->capacity_min/capacity_maxtransmission_candidates.csvcolumns: same rename.
File prefixes: input files regrouped by domain.
hydro_inflow.csv->reservoir_inflow.csv;charge_efficiency.csv->storage_charge_efficiency.csv;carbon_tax.csv->policy_carbon_tax.csv;discount_factor.csv->economic_discount_factor.csv; etc.Hydro plants are first-class techs. Each plant appears in
tech_registry.csvwithcarrier='hydro'; reservoir parameters are keyed per station (nomain_hydroaggregation).Demand units.
demand.csvnow carries instantaneous power in MW (PyPSA convention). The nodal balance constraint multiplies bydtto convert to MWh per timestep, matchinggen/chargewhich were already in MWh.prepshot/model.py::define_basic_setsderiveshydro_tech,storage_tech, anddispatchable_techfrom the registry'scarrierandis_storagecolumns rather than a fixed enum.prepshot/_model/generation.pydefines a single pair of generation bound rules usingp_max_pu/p_min_pulookups, replacing the per-resource-type branches.prepshot/_model/transmission.pybuilds existing transmission capacity by summing over(zone1, zone2, commission_year)entries still in service, mirroringtech_lifetime_rule.
Version 1.7.0 - May 3, 2026¶
Data-model cleanup ahead of the v1.8.0 PyPSA-style API. The
existing-fleet representation is reshaped from an awkward
(zone, tech, age) "historical capacity" table to a tidy
(tech, zone, commission_year) "existing fleet" table, and the
single-purpose technology_type file is renamed to technologies
so it can grow into a per-tech registry.
Added¶
input/existing_fleet.csvandsoutheast_asia/existing_fleet.csv-- one row per existing-capacity block (tech, zone, commission year, capacity). Sparse representation: only non-zero entries are listed.technologies.csv(replacestechnology_type.csv) with columnstechandtype. Designed to grow into a richer per-tech registry (e.g.description,category,co2_intensity_class) alongside the v1.8.0 API work.
Removed¶
historical_capacity.csv-- replaced byexisting_fleet.csv. Theagedimension is gone; capacity blocks now record an explicitcommission_year, which is unambiguous and survives schedule shifts.technology_portfolio.csv-- file existed in the schema but was never referenced by any model rule (all values were 0 in the shipped data). Dead code; deleted.technology_type.csv-- renamed totechnologies.csv(see Added).
Changed¶
prepshot/_model/investment.py::tech_lifetime_rulerewritten:Before: sum
historical_capacity[zone, tech, age]foragein[0, lifetime - service_time).After: sum
existing_fleet[tech, zone, commission_year]for all commission years wherecommission_year <= y < commission_year + lifetime.
The two formulations are mathematically equivalent for the shipped data; the regression test confirms the model objective is unchanged at
1.879e+11.prepshot/load_data.py::extract_setsderives thetechset fromtechnologies(wastechnology_type).prepshot/model.py::define_basic_setsreads tech categories fromtechnologies(wastechnology_type).prepshot/_model/hydro.pyreads the hydro-tech list fromtechnologies(wastechnology_type).
Version 1.6.0 - May 3, 2026¶
Cleanup release. The wide-Excel reading machinery is removed, the
migration tool is rewritten to work under pandas >= 2.0, and the
pandas<2.0 / numpy<2.0 caps from v1.3.1 are lifted.
Removed¶
prepshot.load_data.read_excel-- deleted. Nothing in the runtime path used it after v1.5.0; the migration tool now has its ownpd.read_excelcall.
Changed¶
tools/migrate_to_long.pyrewritten to be self-contained:Bundles its own copy of the v1.4.x wide-format spec (since the on-disk
params.jsonis now v1.5.0 long-format and no longer carries that information).Uses a custom
flatten_wide_to_dicthelper that replicates the v1.4.xunstack-based key ordering by direct cell iteration. Works under pandas >= 2.0 (which rejectsunstackon DataFrames with duplicate column-level values).Verified to produce byte-equivalent output to the shipped v1.5.0
input/CSVs (modulo annotation columns) for all 38 dict-shape parameters.
pyproject.tomlandrequirements.txtfloors raised:numpy>=1.26.0(no upper cap)pandas>=2.0.0(no upper cap)xarray>=2023.10.0(no upper cap)
Tested with numpy 2.0.2, pandas 2.3.3, xarray 2024.7.0; full test suite passes (22 tests, end-to-end regression objective unchanged at 1.879e+11).
Fixed¶
np.Infreferences inprepshot/_model/investment.pyandprepshot/_model/co2.pyreplaced withnp.inf(np.Infwas removed in numpy 2.0).
Version 1.5.0 - May 2, 2026¶
Note
Schema bump 1 -> 2. This is a breaking input-format change. Existing input directories must be migrated. See migration notes below.
Every input is now a CSV. The unstack-based wide-Excel code path is
no longer used by the loader at runtime, though read_excel remains
in prepshot/load_data.py for the migration tool's benefit (a full
v1.4.x -> v1.5.0 migration tool is on the v1.6.0 roadmap).
The pandas<2.0 cap from v1.3.1 is still in place in v1.5.0 because
read_excel (used by the migration tool) calls unstack. Once the
migration tool no longer needs read_excel (v1.6.0), the cap will
be lifted.
Added¶
tools/migrate_to_long.py-- runnable migration script that converts a directory of wide-format Excel inputs to long-format CSVs using the legacy params.json spec for shape information. Run as:python tools/migrate_to_long.py /path/to/your/input_dir
Tailored schema-1 -> schema-2 migration hint in
check_schema: users feeding a v1.4.x or earlier params.json now get a clear pointer attools/migrate_to_long.pyinstead of a generic version mismatch error.
Changed (Breaking)¶
params.jsonschema bumped to_schema_version: 2. Files with_schema_version: 1are rejected with a migration hint.All Group-1/2 parameters in
input/andsoutheast_asia/have been converted from wide.xlsxto long.csv:Capacity / cost:
technology_*,transmission_line_*,capacity_factor,new_technology_*_bound,lifetime,fuel_price,technology_portfolio,technology_type,historical_capacity,initial_energy_storage_level,charge_efficiency,discharge_efficiency,energy_to_power_ratio,ramp_up,ramp_down.Time-series / spatial:
demand,inflow,predefined_hydropower,distance,reservoir_storage_upper_bound,reservoir_storage_lower_bound,initial_reservoir_storage_level,final_reservoir_storage_level.Domain:
carbon_emission_limit,emission_factor,carbon_offset_price,carbon_offset_limit,discount_factor.
params.jsonentries for migrated parameters now declare just"format": "long"plusdrop_na(andrequired/defaultif optional). The wide-format-specific keys (index_cols,header_rows,unstack_levels,first_col_only) are dropped.
Annotation columns¶
Long-format CSVs now support annotation columns -- human-readable metadata that lives alongside the data but is ignored by the model loader. Following the TransitionZero convention:
Every long CSV has a
unitcolumn (e.g.MWh,USD/tonneCO2,m3/s).The eight per-field reservoir CSVs additionally have a
namecolumn with the human-readable station name (e.g. "Grand Coulee") alongside thestcdID.
The loader filters out columns whose name (case-insensitive) is in
the annotation set {unit, units, name, commodity, comment, comments,
description, desc, note, notes, label}, or that ends in _name
(zone_name, tech_name, etc.). These columns never appear in
the dim-key tuples, so model code is unaffected.
Two new unit tests cover annotation handling:
test_unit_column_filtered and
test_name_and_other_annotation_columns_filtered.
Column renames for clarity¶
Several cryptic abbreviations have been replaced with self-describing names across the input CSVs and model code:
stcd(andstationfor params that previously used that name) ->station_ideverywhere -- inputs,model.station,params['station_id'], local variables.POWER_ID/NEXTPOWER_ID(water_delay_time) ->upstream_station_id/downstream_station_id.Z/Q/Vin the piecewise-function lookups ->tailrace_level/discharge/forebay_level/volume(the same axis was being repurposed:Zmeant tailrace level in one file and forebay level in the other).coeff->coefficient,GQ_max->generation_flow_max,N_min/N_max->capacity_min/capacity_maxfor the per-field reservoir CSVs.
Group 3 migration¶
The four "table-shaped" parameters are now CSVs as well:
water_delay_time,reservoir_tailrace_level_discharge_function,reservoir_forebay_level_volume_function-- already 3-column long internally; resaved as CSV and loaded via the newformat: "table"dispatch (returns a DataFrame for downstreamgroupby/column access).reservoir_characteristics-- previously one wide table with ~13 fields per station -- has been split into 8 single-field long CSVs, one per field the model actually uses:reservoir_zone,reservoir_coefficient,reservoir_outflow_min,reservoir_outflow_max,reservoir_generation_flow_max,reservoir_capacity_min,reservoir_capacity_max,reservoir_head.
Four field renames in the process (
coeff->coefficient,GQ_max->generation_flow_max,N_min->capacity_min,N_max->capacity_max) for clarity. The three descriptive fields (name,short_name,type) that the model never referenced have been dropped.params.jsongains a new"format": "table"value alongside"format": "long"for parameters consumed as DataFrames rather than dicts.
Model code (9 sites in hydro.py / head_iteration.py /
load_data.py) updated to read from the new per-field params:
params['reservoir_characteristics']['<field>', s] becomes
params['reservoir_<field>'][s]. The end-to-end regression test
confirms the model objective is unchanged after the refactor.
Migration notes¶
The shipped input/ and southeast_asia/ directories are already
in v1.5.0 shape. For custom input directories (e.g. scenario forks
such as input_s100_baseline_*):
tools/migrate_to_long.pycovers the dict-shape parameters; run:python tools/migrate_to_long.py /path/to/your/input_dir
The four Group-3 parameters and
reservoir_characteristics's field-split are not yet automated. Until the v1.6.0 expanded migration tool ships, use the shippedinput/directory as a reference for the new file shapes, or open an issue if you need porting help.
Custom params.json files (i.e. anyone who has forked params.json)
will need to mirror the canonical v1.5.0 file shipped in this release:
metadata stamp "_schema_version": 2, then minimal long-format
entries ("format": "long" / "format": "table") without the
legacy wide-format keys.
Version 1.4.0 - May 2, 2026¶
PREP-SHOT now supports long-format ("tidy") CSV inputs as a parallel option to the wide-Excel format. New parameters can be born long-format without disturbing existing wide-format inputs; existing parameters can migrate one at a time when convenient.
Why long-format¶
Adding a dimension to a long-format file is a non-breaking column add, not a reshape. This eliminates the recurring "the bound files were reshaped from (tech, zone) to (zone, tech, year)" class of breakage, which historically required migrating every existing input directory.
Long-format is the canonical input shape used by OSeMOSYS, GenX, Switch, and PyPSA -- researchers familiar with those tools will find the convention intuitive.
Added¶
prepshot/load_data.py::read_long_csv-- new reader for tidy CSVs. Convention: dimension columns first, value column last. Output dict shape matches what the wide-format reader produces for the same parameter (scalar keys for 1 dim, tuple keys for 2+ dims), so model code is unchanged regardless of which format is on disk.params.jsonentries support a new"format": "long"key. When set, the loader looks for<file_name>.csvinstead of<file_name>.xlsx, and skips the wide-format-specific keys (index_cols,header_rows,unstack_levels,first_col_only).tests/test_long_format.py-- 7 unit tests covering 1-dim, 2-dim, and 3-dim CSV reading; NaN-row dropping; format dispatch inload_excel_data; and the optional-input fallback for long-format.
Changed¶
carbon_taxmigrated from wide-Excel to long-CSV as the working demonstration of the new format.input/carbon_tax.csvandsoutheast_asia/carbon_tax.csvreplace the correspondingcarbon_tax.xlsxfiles. The data and model behavior are identical -- the regression test passes unchanged at1.879e+11objective.
Migration notes¶
No action needed for existing input directories that are not based on the canonical
input/-- their wide-format files keep working.Users with custom
carbon_tax.xlsxfiles who pull v1.4.0 should convert them tocarbon_tax.csv(zone, year, value columns) and drop the old xlsx, sinceparams.jsonnow declares carbon_tax as long-format.Future features should prefer long-format from the start.
Version 1.3.2 - May 2, 2026¶
Continuous-integration release. The full unit-test suite now runs automatically on every push and pull request via GitHub Actions.
Added¶
.github/workflows/test.yml: matrix-tests on Python 3.9 / 3.10 / 3.11. Runs the fast unit tests (schema, optional inputs, utils) on every push and PR; runs the slow end-to-end regression test only on pushes tomainto keep PR feedback fast.
The fast tests give contributors a quick green/red signal on every PR;
the regression test on main catches any change that would alter the
model's optimal objective on the canonical input/ dataset.
Version 1.3.1 - May 2, 2026¶
Security / dependency hygiene release. Bumps declared dependency floors to clear known CVEs in the previously pinned old releases (the v0.1.x-era pins were 2-4 years old).
Changed¶
pyproject.tomlandrequirements.txtfloors raised to:numpy>=1.26.0,<2.0scipy>=1.11.4pandas>=1.5.3,<2.0xarray>=2023.4.0,<2024openpyxl>=3.1.2xlsxwriter>=3.1.0
pandasandnumpyare temporarily capped below 2.0 because pandas 2.x'sDataFrame.unstackno longer accepts duplicate column-level values (which the wide input format relies on). The cap will be lifted in a future release onceread_excelis rewritten to not rely onunstack.
Tested with¶
numpy 1.26.4, scipy 1.13.1, pandas 1.5.3, xarray 2023.12.0, openpyxl 3.1.5, xlsxwriter 3.2.9, pyoptinterface 0.2.5, highsbox 1.7.0. Full test suite (13 tests) passes.
Migration notes¶
Existing environments should run
pip install -U -r requirements.txt(orpip install -e .if you use the editable install) to pick up the bumped dependency versions.
Version 1.3.0 - May 2, 2026¶
Features can now ship with optional input files. New parameters declared
"required": false in params.json no longer force every existing
input directory to provide a matching Excel file -- a sensible default
is used when the file is absent.
Added¶
params.jsonentries support two new keys:"required"(bool, defaulttrue): whenfalse, a missing input file is tolerated."default"(any, default{}): the value substituted when an optional input file is missing. Scalar defaults are wrapped in adefaultdictso model-side tuple-key lookups (params['foo'][z, y]) keep working unchanged.
tests/test_optional_inputs.pycovers required-missing-terminates, optional-missing-uses-scalar-default, and optional-missing-no-default (empty dict).
Changed¶
The four carbon-market / technology_lower_bound entries in
params.jsonare now marked"required": false, "default": 0, so input directories that pre-date v1.1.0 (or any future input dir that does not use these features) no longer need to ship the zero-filled Excel files.README.mdnow documents the editable-install path (pip install -e .) and theprepshot/python -m prepshotconsole-script entry points introduced in v1.2.0.doc/source/Installation.rstextended with a "Use PREP-SHOT as a Python library" section that shows both the high-levelprepshot.cli.main(root_dir=...)entry point and the low-levelinitialize_environment/create_model/solve_modelflow for downstream code that imports PREP-SHOT programmatically.
Removed¶
tests/test_prepshot.py-- removed. It exercised the legacyload_data,read_four_dims,read_three_dimsetc. APIs that were replaced in v0.1.1 by the genericread_excelhelper. The test file had been a silent import error since then.
Version 1.2.0 - May 2, 2026¶
PREP-SHOT is now installable as a Python package.
Added¶
pyproject.tomlat the repo root, declaringprepshotas an installable package with pinned-but-flexible dependency floors. Allowspip install -e .for local development andpip install prepshotonce published to PyPI.prepshot/cli.py--main()entry point that looks forconfig.jsonandparams.jsonin the current working directory.prepshot/__main__.py-- enablespython -m prepshotinvocation.Console-script entry point
prepshot(declared in[project.scripts]).
Changed¶
run.pyis now a thin backward-compatible shim that delegates toprepshot.cli.main. It preserves the legacy file-relative path behavior (config.json/params.jsonnext torun.py), so existingpython run.pyworkflows continue to work unchanged.
Migration notes¶
New:
pip install -e .from the repo root, then runprepshot(orpython -m prepshot) from any directory containingconfig.jsonandparams.json.Existing:
python run.pystill works as before -- no action needed.
Version 1.1.2 - May 2, 2026¶
Added¶
tests/test_regression.py: end-to-end regression test that runs the fullpython run.pyflow on the canonicalinput/dataset and locks in the final-iteration objective (1.8793771299e+11) with a 1 % tolerance. SetPREPSHOT_SKIP_SLOW=1to skip it (about 150 s).
Fixed¶
prepshot/set_up.pynow skips underscore-prefixed metadata keys (e.g._schema_version) when iteratingparams.jsonto build the argparse list. Without this fix v1.1.1 raisedTypeError: 'int' object is not subscriptableat the start ofinitialize_environment, breakingpython run.pyend-to-end despite the schema guard insideprocess_dataworking in isolation.
Version 1.1.1 - May 2, 2026¶
Added¶
prepshot.load_data.CURRENT_SCHEMAconstant and acheck_schema()guard that validatesparams.jsoncarries a compatible_schema_versionbefore any input is loaded.tests/test_schema_version.pycovering the happy path plus rejection of missing, older, and newer schema stamps.
Changed¶
process_datanow filters out keys beginning with_(such as_schema_version) before iterating the parameter list, so metadata stamps cannot accidentally be treated as input files.
Fixed¶
Legacy
params.jsonfiles (pre-v1.1.0, no_schema_versionstamp) now produce a clear migration hint instead of a downstreamKeyError/FileNotFoundError.
Version 1.1.0 - May 2, 2026¶
Note
v1.x is a rapid-evolution series. Breaking input-format changes may occur on minor version bumps. API and input-schema stability will be promised with v2.0.0. See the Stability page.
Added¶
Time-varying capacity bounds:
technology_upper_bound,technology_lower_bound,new_technology_upper_bound, andnew_technology_lower_boundare now indexed by(zone, tech, year).Re-introduced
technology_lower_boundconstraint oncap_existing.Carbon market:
carbon_offset[y, z]decision variable.carbon_taxcost term in the objective.carbon_offset_pricecost term in the objective.Fractional
carbon_offset_limitconstraint (offset <= rate * raw zonal emissions).carbon_capacity[y, z]now nets out purchased offsets.
southeast_asia/dataset migrated to the new schema.params.jsonnow stamps_schema_version: 1.
Changed (Breaking)¶
Bound input files reshaped from
(tech, zone)to(zone, tech, year).Four new required input files:
technology_lower_bound,carbon_tax,carbon_offset_price,carbon_offset_limit. The shipped defaults are zero-filled, which keeps the carbon-market feature dormant.params.jsonfiles without_schema_versionare rejected by the loader.
Version 1.0 - Jul 21, 2025¶
First v1.0 release. Aggregates all changes since v0.1.2 (PRs #23-#34). See the GitHub release notes for the full list. Notable highlights:
Bug fixes and refinements to constraint definitions.
Documentation improvements and added publications.
Stabilized PyOptInterface integration.
Version 0.1.2 - Jul 22, 2024¶
Added¶
Added mathematical notations to the constraint module.
Added a test script for prepshot.utils.
Fixed¶
Fixed the format of the API reference.
Fix code blocks of documentation.
Updated Contribution.rst to include context on running tests and code style checks.
Defined explicit data types for inputs and outputs of functions for better type checking and readability.
Added pyoptinterface._src.core_ext to Pylint's extension package allow list to resolve cpp-extension-no-member warning.
Changed¶
Updated model.py to keep necessary decision variables and use expressions for intermediate variables instead of direct determination.
Refactored extract_results_non_hydro in output_data.py to extract common features for different variables, simplifying the code.
Removed definitions of complex sets and opted for simple sets wherever possible to streamline the code.
Refactor: Organize import order of modules according to PEP 8 guidelines: (1) Grouped standard library imports at the top; (2) Followed by third-party library imports; (3) Local application/library imports at the bottom.
Version 0.1.1 - Jul 11, 2024¶
Added¶
Add an example, expansion of Southeast Asia Mainland power system considering hydropower of Lower Mekong River.
Update the documentation with a docstring for each function and class.
Add the Semantic Versioning Specification.
Fixed¶
Changed¶
Support for solvers such as GUROBI (Commercial), COPT (Commercial), MOSEK (Commercial), and HiGHS (Open source) via PyOptInterface.
Change default solver to HiGHS.
Change the code comment style to NumPy.
Change the code style to PEP8.
Categorize constraint definitions based on type (co2, cost, demand, generation, hydro, investment, nondispatchable, storage, transmission) for better organization.
Split rule.py class into serveral smaller, focused classes according to categorized constraint definitions.
Simplify model by replacing intermediate constraints with direct expressions.
Extract new modules solver.py, output_data.py, and set_up.py from run.py and utils.py.
Remove parameters.py into set_up.py.
Refactor and improve comments and function names for clarity and conciseness.
Deprecated¶
Removed dependency on Pyomo due to high memory usage and slow performance for large-scale models. For you reference.
Version 0.1.0 - Jun 24, 2024¶
PREP-SHOT model is released with basic functionality for energy expansion planning.
Linear programming optimization model for energy systems with multiple zones.
Support for solvers such as Gurobi, CPLEX, MOSEK, and GLPK via Pyomo.
Input and output handling with pandas and Xarray.