# Results — three-year dispatch, 2018-01-01 to 2020-12-31 Reproduced by: ```bash uv run battery-dispatch run --start 2018-01-01 --end 2020-12-31 --out outputs/ ``` Wall-clock: 225.9s (1,096 rolling-horizon windows, all 1,096 needing the MILP fallback). Independent validation: **all 7 checks passed** (power limits, no simultaneous charge/discharge, Market 2's hourly commitment held across all 26,304 hours, state of charge and revenue both recomputed from scratch and matched the reported schedule). Every file below is in `outputs/`, generated by this exact command. ## Headline | Metric | Value | |---|---| | Gross trading profit | **£208,430.85** over 3 years (**£69,461/yr**) | | Fixed opex | £15,003.42 over 3 years (£5,000/yr) | | Net of opex | £193,427.43 | | Energy discharged to grid | 13,178.5 MWh (14,602.2 MWh charged) | | Average captured spread | £15.82/MWh discharged | | Equivalent full cycles | 3,468.0 (1,155.7/yr — 69.4% of the 5,000-cycle budget) | | Capacity remaining | 3.8613 MWh (96.53% of nominal) | | Implied life | 4.3 years (cycle-budget-limited, not the 10-year calendar life) | ## The revenue split is the interesting number | | Market 1 (half-hourly) | Market 2 (hourly) | |---|---|---| | 3-year profit | **-£297,418.06** | **+£505,848.91** | | Share of gross profit | -142.7% | +242.7% | Market 1 runs at a large loss and Market 2 more than covers it. This is not a bug — it reflects how the optimiser actually uses the two markets, and is checked directly in `monthly_revenue.png` below: **Market 1 is used mainly as the cheap-import channel, Market 2 as the sell channel.** Market 1 trades on a finer (half-hourly) grid, so it captures the cheapest individual half-hours to charge, while Market 2's coarser hourly commitment makes it the more reliably profitable side to sell into across an hour. Gross trading profit (the number that matters) is the *sum* of the two, and it is positive and consistent in every one of the 36 months in the run. ![Monthly gross trading profit by market](outputs/monthly_revenue.png) ## Per-year | Year | Gross profit | Market 1 | Market 2 | Discharged (MWh) | Cycles | Captured spread (£/MWh) | |---|---|---|---|---|---|---| | 2018 | £67,799.45 | -£115,113.36 | £182,912.82 | 3,696.8 | 972.8 | £18.34 | | 2019 | £64,299.71 | -£103,884.58 | £168,184.30 | 4,459.4 | 1,173.5 | £14.42 | | 2020 | £76,331.69 | -£78,420.11 | £154,751.80 | 5,022.2 | 1,321.6 | £15.20 | Cycling intensifies year over year (972.8 → 1,321.6 cycles/yr) as the model exploits more of the available spread, while the captured spread per MWh discharged drifts down slightly — consistent with cycling harder into thinner margins as capacity fades a little each year. ## A representative week The median-revenue week of the run (13–20 April 2020), showing both markets' prices, the battery's dispatch by market, and state of charge: ![Representative week of dispatch](outputs/week_dispatch.png) The battery cycles multiple times most days, buying the overnight trough and selling into each day's peak(s) across both markets, exactly as intended. ## Cycling and capacity fade ![Cycling and capacity fade over the modelled period](outputs/degradation.png) 3,468 equivalent full cycles against Attachment 1's 5,000-cycle budget, fading capacity from 4.0 to 3.8613 MWh (96.53% retained) over the three years. At this cycling rate the battery would exhaust its cycle budget — the binding end-of-life constraint, not the 10-year calendar life — in about 4.3 years. ## Captured spread distribution ![Daily captured spread](outputs/captured_spread.png) A tight, right-skewed distribution centred on a median of £14.6/MWh discharged, with a handful of much larger days (negative-price events and unusually wide spreads) pulling the mean above the median. ## Solver behaviour The MILP fallback (binary charge/discharge exclusivity) fired in **every one of the 1,096 windows** — the persistent gap between Market 1 and Market 2 prices makes the LP relaxation's "charge and discharge at once" cheat attractive almost daily, exactly as anticipated in `optimiser.py`'s docstring. Despite that, the full three-year run completed in 225.9s (~3.8 minutes), well inside the 10-minute target: on real price data each 48-hour MILP window (96 binaries) solves in roughly 0.1–0.2s, because real prices vary enough half-hour to half-hour that the LP relaxation is already close to integral. (A test fixture using long runs of exactly-repeated synthetic prices hit pathological CBC branch-and-bound behaviour during development — tens of thousands of nodes without closing the optimality gap — which is what real Attachment 2 data never triggers; see the test suite for the fix.)