Research with the Python API¶
Every step of a scenario is a plain function, so experiments that do not fit a YAML file are a few lines of Python.
How much is a better forecast worth?¶
examples/forecast_error_sweep.py
adds Gaussian error of increasing size to the actual 2019 GB prices and measures the
share of perfect-foresight revenue a 1 MW / 2 MWh battery keeps:
raw = OPSDCsvSource(DATA).prices("GB_GBN", date(2019, 1, 1), date(2019, 12, 31))
prices, _ = fill_gaps(raw, max_gap_hours=2)
battery = BatterySpec(power_mw=1, energy_mwh=2)
config = BacktestConfig(dispatch=DispatchConfig(degradation_cost=5))
benchmark = run_backtest(battery, prices, PerfectForesight(prices), config)
for error_std in (0.0, 2.0, 5.0, 10.0, 20.0):
forecaster = NoisyForesight(prices, error_std=error_std, seed=1)
summary = summarise(run_backtest(battery, prices, forecaster, config), benchmark)
| Error std (GBP/MWh) | Revenue (GBP) | Capture | Cycles |
|---|---|---|---|
| 0 | 19,235 | 100.0% | 468 |
| 2 | 18,846 | 98.0% | 494 |
| 5 | 17,261 | 89.7% | 602 |
| 10 | 10,626 | 55.2% | 903 |
| 20 | −164 | −0.9% | 1,309 |
Beyond about £5/MWh of error the optimiser starts trading noise: cycles double and revenue collapses. The noise here is independent hour to hour, which is harsher than real forecast errors; a correlated error model is a natural next experiment.
Do solar and wind earn the average price?¶
openenergy capture compares what each technology earned per MWh with the plain average
price, per year:
| Year | Solar capture rate | Wind capture rate |
|---|---|---|
| 2015 | 105.9% | 96.4% |
| 2016 | 94.2% | 96.3% |
| 2017 | 93.8% | 99.5% |
| 2018 | 98.3% | 99.2% |
| 2019 | 96.1% | 98.1% |
In GB day-ahead prices up to 2019, neither technology was heavily cannibalised: both earned within about 6% of baseload, and almost no output fell in negative-price hours (GB had only 81 such hours in the whole extract). Solar's 2015 premium reflects output coinciding with daytime peaks. OPSD's solar capacity factors run high because reported capacity lags new build (see Data), which affects capacity factor but not capture rate.
How big should a shared connection be?¶
examples/colocation_grid_sweep.py
puts 10 MW of solar and a 5 MW / 10 MWh battery behind one connection of increasing size
and compares each with the same assets on separate connections (15 MW in total), for GB
2019 with last week's prices:
| Shared limit (MW) | Site (GBP) | Separate (GBP) | Difference |
|---|---|---|---|
| 5.0 | 590,250 | 618,231 | −27,981 |
| 7.0 | 615,736 | 618,231 | −2,494 |
| 10.0 | 618,208 | 618,231 | −23 |
| 12.5 | 618,236 | 618,231 | 5 |
| 15.0 | 618,249 | 618,231 | 18 |
A battery added behind the solar farm's own 10 MW connection gives up only £23 a year: solar output and battery discharge rarely need the connection at the same time. Shrinking the shared connection below the plant's capacity starts to cost real money, so the saving on connection capacity has to exceed these figures. Plans use actual solar output, so these are upper bounds.
Is planning under price uncertainty worth it?¶
examples/stochastic_value.py
plans each day at noon the day before in four ways and settles every plan at actual GB
prices (Ember). The scenarios are last week's prices plus each of that forecast's daily
errors over the last 28 days. Revenue is after a £5/MWh wear cost, for a 1 MW / 2 MWh
battery:
| Year | Plan | Revenue (£) | vs forecast | Loss days |
|---|---|---|---|---|
| 2019 | One forecast (last week) | 4,635 | 83 | |
| 2019 | Scenario mean (risk-neutral) | 4,315 | −6.9% | 90 |
| 2019 | Half CVaR (worst 10%) | 3,412 | −26.4% | 78 |
| 2019 | Full CVaR | 2,221 | −52.1% | 63 |
| 2019 | One forecast, one day lookahead | 6,400 | +38.1% | 83 |
| 2022 | One forecast | 64,602 | 9 | |
| 2022 | Scenario mean | 62,100 | −3.9% | 12 |
| 2022 | Half CVaR | 50,311 | −22.1% | 34 |
| 2022 | Full CVaR | 40,613 | −37.1% | 40 |
| 2022 | One forecast, one day lookahead | 71,213 | +10.2% | 43 |
| 2024 | One forecast | 17,633 | 34 | |
| 2024 | Scenario mean | 16,927 | −4.0% | 45 |
| 2024 | Half CVaR | 12,163 | −31.0% | 43 |
| 2024 | Full CVaR | 8,873 | −49.7% | 44 |
| 2024 | One forecast, one day lookahead | 21,171 | +20.1% | 46 |
What this shows:
- A risk-neutral scenario plan is just another forecast. The plan is fixed before prices clear and paid at actual prices, so revenue is linear in price: the expected revenue of any plan is its revenue at the mean scenario, and the best plan over the scenarios is the best plan for their mean. These scenarios shift last week's prices by their recent average error, which made the forecast slightly worse every year. With only a day-ahead market, the value of the stochastic solution is zero by construction. A prototype that also re-planned the next day per scenario (two-stage, not part of the example) gained under £1 a year over the same two-day plan on the mean scenario.
- Risk aversion costs revenue and does not reliably cut bad days. Weighting the worst 10% of scenarios (CVaR) loses 22–52% of revenue; in 2022 it more than tripled the days with a loss.
- A longer horizon pays. Planning today with tomorrow's forecast, rather than returning to the same charge every midnight, adds 10–38% after wear cost. Loss days rise because a day that fills the battery for tomorrow books the cost today and the revenue tomorrow.
The lookahead across all years, with examples/gb-2022-naive.yaml (last week's prices,
£5/MWh wear cost in the plan) and dispatch.lookahead_days set; revenue per MW-year before
wear cost, perfect foresight with the same lookahead:
| Year | Revenue, 0 days | 1 day | Change | Capture, 0 days | 1 day | Cycles, 0 days | 1 day |
|---|---|---|---|---|---|---|---|
| 2017 | 20,682 | 22,356 | +8.1% | 78.8% | 80.6% | 526 | 524 |
| 2018 | 18,889 | 20,567 | +8.9% | 74.4% | 76.5% | 529 | 526 |
| 2019 | 13,936 | 15,827 | +13.6% | 71.3% | 74.7% | 468 | 474 |
| 2020 | 17,155 | 19,661 | +14.6% | 76.7% | 80.5% | 419 | 428 |
| 2021 | 74,172 | 78,277 | +5.5% | 83.1% | 84.8% | 620 | 600 |
| 2022 | 76,940 | 82,938 | +7.8% | 75.0% | 76.8% | 661 | 627 |
| 2023 | 35,964 | 40,091 | +11.5% | 78.2% | 81.5% | 612 | 585 |
| 2024 | 28,677 | 31,864 | +11.1% | 73.8% | 76.9% | 569 | 551 |
| 2025 | 35,861 | 38,936 | +8.6% | 80.5% | 82.7% | 571 | 547 |
One day of lookahead adds 5.5–14.6% revenue every year, and from 2021 does so with fewer cycles. Two or three days change revenue by under £20 a year: last week's prices say little about the day after tomorrow.
Uncertainty could matter when a later decision can react to prices, such as balancing or
frequency response. Revenue stacking checks both: response
availability is also committed before its price is known, so the same argument applies;
balancing does give the battery a later decision, but it pays only with an imbalance-price
forecast that open data does not provide. The lookahead is a scenario setting,
dispatch.lookahead_days.
Building blocks¶
| Step | Function |
|---|---|
| Load prices | OPSDCsvSource(path).prices(zone, start, end) |
| Load capacity factors | OPSDCsvSource(path).profile(zone, technology, start, end) |
| Fill short gaps | fill_gaps(series, max_gap_hours) |
| Plan one horizon | optimise_dispatch(spec, state, prices, step_hours, config) |
| Apply a plan | apply_dispatch(spec, state, charge_mw, discharge_mw, step_hours) |
| Age a battery | age(spec, state, hours) |
| Backtest | run_backtest(spec, prices, forecaster, config, days) |
| Plant output | RenewableSpec(technology, capacity_mw).generation_mw(profile) |
| Co-located backtest | run_backtest(..., site=Site(output, GridConnection(...))) |
| Plant on its own | run_plant_backtest(site, prices, forecaster, config, days) |
| Captured price | capture_metrics(prices, output, capacity_mw), capture_by_year(prices, profile) |
| Summarise | summarise(result, benchmark) |
Any object with a name and a forecast(history, index) method is a forecaster, so a new
model needs no changes to the library. See the API reference.