Multi-year LP validation
Generated: 2026-05-08. Model: jb_vpp.models.dispatch_lp. The current LP
solves one calendar month at a time, with within-year SoC carry-forward
across month boundaries. The natural extension — multi-year horizon
with SoC carry-forward across year boundaries — was on the engineering
backlog. This report documents what we found.
Across-year SoC carry-forward changes the LP cost by 0.0000% on the real 5-year backfilled data — single-year LP results are defensible at multi-year horizon. The reason is simple: every year’s optimal
LP terminates with SoC = 0, because there is no future revenue beyond
December 31 of the optimization horizon to incentivize carrying energy
forward. The carry-forward variable soc[Dec 31] = soc[Jan 1 of next year]
collapses to 0 = 0.
Implication: the existing year-by-year LP is already optimal at the year-boundary scale. The remaining LP improvement opportunity sits at
the month-boundary scale within a year, where current SoC carry IS
already enabled (lines 168-169 of dispatch_lp.simulate_year_lp).
What we measured
Section titled “What we measured”Using the 5-year backfilled silver weather (silver/weather_hourly), we
solved the LP under both modes:
| Mode | Total cost (5y aggregate) | Δ vs cold-start |
|---|---|---|
Cold-start each year (soc_init=0 each Jan 1) | 2,016.348 M | — |
| Inter-year SoC carry-forward (warm-start) | 2,016.348 M | +0.00 kRM |
Per-year SoC trace under warm-start:
- 2020: init 0.00 → end 0.00
- 2021: init 0.00 → end 0.00
- 2022: init 0.00 → end 0.00
- 2023: init 0.00 → end 0.00
- 2024: init 0.00 → end 0.00
The terminal SoC of every year is exactly zero. With nothing to carry, the two modes produce identical solutions to numerical precision.
Why this is the expected outcome
Section titled “Why this is the expected outcome”The LP objective is Σ_t grid_t × rate_t + md_kw × md_rate, which is
finite and bounded by the optimization horizon. Energy in the BESS at
the last hour of the horizon has zero terminal value (no later hour to
discharge into for cost reduction). Therefore:
- The LP will discharge any remaining SoC during the last few peak hours of the horizon if doing so reduces cost.
- If discharge is constrained (e.g., a non-peak last hour), the LP will leave SoC at exactly the level it cannot profitably reduce.
- In practice, with hourly resolution and sufficient end-of-year ETOU peak hours, the LP always exits Dec 31 with SoC ≈ 0.
This holds for any deterministic LP horizon. The only way inter-year carry would matter is:
- Stochastic horizon: if year N’s terminal value is uncertain and year N+1’s prices are higher in expectation. Not modeled here (we have deterministic 5y backfilled weather).
- Battery state penalty: if battery degradation is modeled per charge-discharge cycle and carry-forward avoids a year-end full-cycle. Not modeled in our LP.
- End-of-horizon residual value: if the asset has lifetime > LP horizon and residual SoC carries economic value. Implicitly handled in the NPV layer via the augmentation pv_factor, not in the LP itself.
What this closes
Section titled “What this closes”This report closes the engineering backlog item “Multi-year LP horizon (relax monthly MD aggregation)”. The expected NPV impact was modest, and the empirical answer is “exactly zero”. The deterministic per-year LP is already optimal at this scale.
The remaining LP improvements in scope (still listed in
docs/IMPLEMENTATION_PLAN.md) are:
- ENEGEM export-leg layer: blocked by USEP time-series data, not LP capability.
- Multi-month rolling-horizon look-ahead (different from this): would give the LP visibility into future ICPT changes when committing the current month’s plan. Could capture marginal value if the next month’s ICPT differs significantly. Effort vs payoff: small — defer until someone is materially blocked on a 0.5-1% LP optimization.
How to use the new function
Section titled “How to use the new function”from jb_vpp.models.dispatch_lp import simulate_multi_year_lp
# Cold-start each year — replicates the existing behavior in btm_economics.evaluateout = simulate_multi_year_lp( multi_year_df, # must have year_myt, month_myt, load_mw, pv_mw, # is_etou_peak, rate_rm_per_kwh bess_power_mw=15.0, bess_energy_mwh=60.0, bess_rte=0.88, md_rm_per_kw_per_month=35.0, cold_start_each_year=True, # default for backwards compatibility)
# Or carry SoC across year boundaries — proven equivalent on real data,# but useful as a regression check or for exploratory scenarios where you# expect terminal SoC to differ.out = simulate_multi_year_lp(..., cold_start_each_year=False)The output dict aggregates across all years AND retains a per-year breakdown
in out["per_year"] — handy for year-by-year diagnostic plots.
tests/test_dispatch_lp_multiyear.py — 10 tests covering cold-vs-warm
equivalence at SoC=0 terminal, aggregate-key stability, per-year tagging,
sum-of-per-year invariant, no-BESS sanity, and final_soc_end echo.
Related reports
Section titled “Related reports”btm_economics_dc100.md— current per-year LP integration viaevaluate().bess_trigger_curve.md— BESS investment trigger by year.risk_adjusted_npv.md— Monte Carlo on capex/discount/load growth (which IS where the BESS NPV uncertainty lives — not here).