Skip to content

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).

Using the 5-year backfilled silver weather (silver/weather_hourly), we solved the LP under both modes:

ModeTotal 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.

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:

  1. The LP will discharge any remaining SoC during the last few peak hours of the horizon if doing so reduces cost.
  2. If discharge is constrained (e.g., a non-peak last hour), the LP will leave SoC at exactly the level it cannot profitably reduce.
  3. 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.

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.
from jb_vpp.models.dispatch_lp import simulate_multi_year_lp
# Cold-start each year — replicates the existing behavior in btm_economics.evaluate
out = 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.

  • btm_economics_dc100.md — current per-year LP integration via evaluate().
  • 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).