Run the full three-year dispatch and fix issues it surfaced
CI / test (push) Successful in 14s

Executed battery-dispatch run over the full 2018-2020 Attachment 2
series (225.9s, 1096 rolling-horizon windows) and committed the
results: outputs/ (dispatch.csv, annual/monthly summaries, summary.json,
four figures), README.md (setup, run instructions, assumptions, the
one-paragraph approach summary) and RESULTS.md (headline numbers,
per-year table, charts, commentary).

The full run surfaced three real issues, all fixed rather than papered
over:

- validate_schedule's soc_within_bounds check used a fixed 1e-6 MWh
  tolerance for the "state of charge below zero" case, while its
  sibling soc_matches_power_flows check already scales its tolerance
  with sqrt(n) for the same reason (CBC's own solver precision
  accumulating over a long cumulative sum). Over 52,608 half-hours this
  false-failed on a 5.1e-6 MWh solver-noise dip, not a real violation.
  Scaled it the same way.
- cli.py's summary printed net_of_opex_gbp under the label "Less fixed
  opex", so the terminal output showed the post-opex profit (£193k)
  where a reader would expect the opex figure itself (£15k). Split into
  two correctly-labelled lines.
- plots.py's monthly revenue chart stacked Market 1 (always negative
  here) and Market 2 (always positive) with a single running `bottom`,
  which is only correct for same-signed series: Market 2's bar
  completely overlapped and painted over Market 1's, hiding the
  -£297k Market 1 loss entirely. Now accumulates positive and negative
  contributions on separate baselines and marks the net.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 16:29:40 +01:00
co-authored by Claude Sonnet 5
parent afa02864a7
commit c70c9731a6
13 changed files with 52976 additions and 9 deletions
+131
View File
@@ -0,0 +1,131 @@
# Battery dispatch model
A Python package that dispatches a 2 MW / 4 MWh battery across two wholesale electricity
markets — Market 1 (half-hourly prices) and Market 2 (hourly prices) — to maximise trading
profit, as a price taker, over the three years of price data in `data/Attachment 2.xlsx`. Battery
specifications come from `data/Attachment 1.xlsx`.
## Approach (one paragraph)
Dispatch is formulated as a linear/mixed-integer programme (PuLP + the bundled CBC solver) on
the half-hourly grid, solved over a rolling 48-hour lookahead window of which only the first 24
hours is committed and state of charge carried forward — this keeps each solve small while
avoiding the myopia of independent daily solves. Market 2's hourly commitment rule is enforced
structurally (one decision variable per hour, referenced by both of its half-hours) rather than as
a constraint that could be mis-specified, and "no selling the same energy twice" falls out for free
from a single shared state-of-charge balance. Because negative prices and cross-market price gaps
let the LP relaxation profitably "cheat" by charging and discharging at once, the solver runs
LP-first with a MILP fallback that adds binary exclusivity only where the relaxation actually
cheats. Every schedule is independently re-validated from its output alone (power limits, no
simultaneous flow, hourly commitment, state-of-charge and revenue recomputed from scratch) so a
formulation bug couldn't mark its own homework, and cycle counting / capacity fade are tracked
between windows per Attachment 1's degradation figures. Investment economics (NPV, IRR, payback)
and heuristic benchmark comparisons were deliberately left out of scope to keep the deliverable
focused on the dispatch decision itself.
## Setup
Requires Python 3.11+. CBC ships with PuLP, so no external solver install is needed.
```bash
uv sync # or: pip install -e ".[dev]"
```
## Running
```bash
# The full three-year run (~4 minutes; this is what produced outputs/)
uv run battery-dispatch run --start 2018-01-01 --end 2020-12-31 --out outputs/
# A fast smoke run over one week
uv run battery-dispatch run --start 2018-01-01 --end 2018-01-07 --out /tmp/smoke
# Tests (38 tests, ~1.5s)
uv run pytest -q
# Lint
uv run ruff check .
```
`battery-dispatch run` writes `dispatch.csv` (every half-hour's prices, power, state of charge and
revenue), `annual_summary.csv`, `monthly_summary.csv`, `summary.json`, four PNG figures, and prints
a headline summary plus the independent validation verdict. It exits non-zero if validation finds
any violation. Useful flags: `--lp-only` / `--milp-always` to force a solver mode,
`--max-seconds-per-window` to cap solve time per window, `--degradation-cost` to price cycle life
into the objective (0 by default — the exercise asks for gross trading profit), `--no-plots` to
skip figure rendering.
## Reproducible results
`outputs/` is committed and holds the full three-year run described above. See
[`RESULTS.md`](RESULTS.md) for headline numbers, charts and commentary. Re-running the CLI with
the same arguments reproduces `summary.json` byte-for-byte (no randomness anywhere, fixed solver
settings).
## Modelling assumptions
- **Efficiency convention.** Attachment 1 quotes charge/discharge *losses* (5% each); the model
treats these as `efficiency = 1 - loss`. Importing `I` MWh from the grid stores `0.95 × I`;
exporting `E` MWh to the grid draws `E / 0.95` from storage. Money is always settled on the
grid-side quantity, since that's what the market meters and pays for. This convention is stated
once in `config.py` and pinned by a unit test (`test_spec_reads_losses_as_efficiencies`, plus the
£90.25 analytic two-period case in `test_optimiser.py`).
- **Clock-change labelling.** Market 1's timestamps duplicate `02:00`/`02:30` and skip
`01:00`/`01:30` on the three spring clock-change days, but every day is still a clean 48 rows.
Rather than guess at intended local-time semantics, the loader rebuilds a regular half-hourly
index from the series start and logs the discarded labels — the series is treated as 52,608
consecutive half-hours, which is what the dispatch model actually needs.
- **Market alignment.** Market 2's sheet is exactly as long as Market 1's but only the first
26,304 rows carry prices; the rest are blank and dropped (with an assertion on the surviving row
count). Market 2 is aligned to Market 1 by integer position (two half-hours per hour) rather
than by timestamp join, since hourly timestamps carry a few milliseconds of floating-point noise
that makes a timestamp join fragile.
- **Rolling-horizon perfect foresight.** Each 48-hour window solves with perfect knowledge of
prices within that window, then commits 24 hours and slides forward. This is not globally
optimal, but a 48-hour window comfortably covers the useful lookahead for a 2-hour-duration
battery — intraday arbitrage dominates the value here (mean daily Market 1 spread is ~£39/MWh),
and cross-window boundary effects are small (see the capacity-seam note in `validation.py`).
- **Degradation applied between windows, not inside the LP.** Making usable capacity a function of
cumulative throughput within the same optimisation would make the programme non-linear. Instead,
equivalent full cycles and the resulting capacity fade (Attachment 1's 0.001%/cycle) are updated
*after* each committed 24-hour block and used as the capacity bound for the next window. Over a
single window this changes capacity by ~1e-5 MWh, far below any decision-relevant threshold.
- **LP-first, MILP fallback.** The LP relaxation can profitably charge and discharge in the same
half-hour whenever prices are negative or the two markets' prices diverge enough to beat the
~10% round-trip efficiency loss. The solver detects this and re-solves only the offending window
with binary exclusivity added. On this dataset the fallback fires in essentially every window
(the persistent Market 1/Market 2 price gap makes it attractive almost daily), but each MILP
solve is still fast in practice — confirmed directly against Attachment 2 at ~0.1s/window,
~4 minutes for the full three years.
## Known simplifications
- No investment economics (NPV, IRR, payback) — the model reports gross trading profit, cycles and
capacity fade, but does not attempt to value the £500k capex against them.
- No heuristic/benchmark comparison strategy — only the optimised dispatch is reported.
- A rolling 48h/24h window is a deliberate trade-off against a single monolithic 52,608-period
solve, stated explicitly rather than left implicit.
- Degradation cost is not priced into the objective by default (`--degradation-cost 0`), so the
model reports the profit-maximising *gross* dispatch; a positive `--degradation-cost` is
available to see how marginal cycling cost would suppress trading.
## Package layout
```
src/battery_dispatch/
config.py BatterySpec + RunConfig; reads Attachment 1
data.py Price loading, index rebuild, market alignment
markets.py Market dataclass (name, commitment block length, prices)
optimiser.py Window construction, LP/MILP solve, rolling-horizon driver
battery.py State of charge, cycle counting, capacity fade
validation.py Independent post-hoc schedule checker
metrics.py Annual/monthly KPIs, headline summary
plots.py Matplotlib figures
cli.py `battery-dispatch run ...`
tests/ 38 tests covering data loading, the optimiser, validation and battery state
```
## CI
`.gitea/workflows/ci.yml` runs lint (`ruff`) and the full test suite on every push, and posts a
pass/fail notification to ntfy.
+95
View File
@@ -0,0 +1,95 @@
# 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.)
+4
View File
@@ -0,0 +1,4 @@
year,gross_profit_gbp,profit_market_1_gbp,profit_market_2_gbp,charged_mwh,discharged_mwh,equivalent_full_cycles,captured_spread_gbp_per_mwh
2018,67799.45428,-115113.361429,182912.815709,4096.144098,3696.77005,972.834224,18.340187
2019,64299.710798,-103884.584974,168184.295772,4944.211906,4459.443748,1173.537828,14.418774
2020,76331.687725,-78420.110711,154751.798435,5561.815889,5022.246341,1321.643774,15.198714
1 year gross_profit_gbp profit_market_1_gbp profit_market_2_gbp charged_mwh discharged_mwh equivalent_full_cycles captured_spread_gbp_per_mwh
2 2018 67799.45428 -115113.361429 182912.815709 4096.144098 3696.77005 972.834224 18.340187
3 2019 64299.710798 -103884.584974 168184.295772 4944.211906 4459.443748 1173.537828 14.418774
4 2020 76331.687725 -78420.110711 154751.798435 5561.815889 5022.246341 1321.643774 15.198714
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+52609
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+37
View File
@@ -0,0 +1,37 @@
month,gross_profit_gbp,profit_market_1_gbp,profit_market_2_gbp,charged_mwh,discharged_mwh,equivalent_full_cycles,captured_spread_gbp_per_mwh
2018-01,5465.302553,-10616.512258,16081.814811,384.869784,343.548311,90.40745,15.908396
2018-02,5649.230748,-9703.961847,15353.192595,349.200387,318.950019,83.934215,17.711962
2018-03,8237.234668,-10604.237,18841.471667,396.015555,355.599039,93.578695,23.16439
2018-04,4806.727348,-9305.278306,14112.005654,360.263313,324.235141,85.325037,14.824819
2018-05,5511.866602,-9401.571826,14913.438427,370.694857,336.357109,88.515029,16.386948
2018-06,4846.162293,-8254.164575,13100.326868,344.023224,311.38346,81.943016,15.563326
2018-07,4733.333558,-10028.827592,14762.16115,352.885749,316.674389,83.335365,14.947005
2018-08,5369.799211,-10320.611101,15690.410312,325.781443,295.822752,77.848093,18.152083
2018-09,6294.844593,-8947.096603,15241.941196,306.195008,275.438495,72.483814,22.853903
2018-10,5998.14371,-7374.223128,13372.366838,305.310416,276.445151,72.748724,21.69741
2018-11,5658.950526,-9976.708879,15635.659404,308.559422,276.514059,72.766858,20.465327
2018-12,5227.858471,-10580.168315,15808.026786,292.344937,265.802125,69.947928,19.668234
2019-01,4846.091122,-9716.467684,14562.558806,301.096315,269.934425,71.035375,17.952846
2019-02,4386.850007,-9169.974804,13556.824811,354.872114,322.077084,84.757127,13.620497
2019-03,5685.537534,-9202.12936,14887.666893,395.787504,355.393223,93.524532,15.997878
2019-04,4718.922967,-9321.77924,14040.702207,366.770343,330.107734,86.870456,14.2951
2019-05,5427.961092,-9266.13638,14694.097472,424.406564,385.597895,101.47313,14.076739
2019-06,5106.284583,-8953.112834,14059.397417,431.412168,389.48601,102.496318,13.110316
2019-07,4410.738629,-10309.250217,14719.988846,455.116658,407.132784,107.140206,10.833661
2019-08,5371.192114,-7627.944384,12999.136498,411.497847,373.181806,98.205739,14.392963
2019-09,6108.180331,-6121.834754,12230.015085,446.080294,404.392466,106.41907,15.104585
2019-10,6225.554888,-7392.268463,13617.823351,460.993925,414.125371,108.980361,15.03302
2019-11,5546.450256,-9064.889778,14611.340034,417.26031,374.889076,98.65502,14.79491
2019-12,6465.947275,-7738.797076,14204.744351,478.917865,433.125873,113.980493,14.928564
2020-01,5752.783129,-6748.3924,12501.175529,469.180307,424.045227,111.590849,13.566438
2020-02,6045.669316,-5597.409405,11643.07872,446.676521,405.028061,106.586332,14.926544
2020-03,6146.118796,-6819.499809,12965.618605,494.105284,446.125018,117.401321,13.776674
2020-04,5671.636666,-5544.659957,11216.296623,482.833775,435.757483,114.673022,13.015581
2020-05,5591.88205,-5609.8907,11201.77275,518.380706,467.838587,123.115418,11.952588
2020-06,5305.6492,-5883.53861,11189.18781,491.564836,442.734765,116.509149,11.98381
2020-07,5152.114341,-6940.203845,12092.318187,513.960584,463.849427,122.065639,11.107299
2020-08,4740.600521,-9184.278367,13924.878888,479.188819,431.491892,113.550498,10.986534
2020-09,7591.825678,-6986.500821,14578.326499,397.581913,357.918912,94.189187,21.211021
2020-10,7291.309946,-7303.097011,14594.406957,443.701444,402.315335,105.872457,18.123371
2020-11,7836.211148,-5352.907107,13189.118256,436.922129,391.614722,103.056506,20.010001
2020-12,9205.886933,-6449.732679,15655.619612,387.71957,353.526912,93.033398,26.040131
1 month gross_profit_gbp profit_market_1_gbp profit_market_2_gbp charged_mwh discharged_mwh equivalent_full_cycles captured_spread_gbp_per_mwh
2 2018-01 5465.302553 -10616.512258 16081.814811 384.869784 343.548311 90.40745 15.908396
3 2018-02 5649.230748 -9703.961847 15353.192595 349.200387 318.950019 83.934215 17.711962
4 2018-03 8237.234668 -10604.237 18841.471667 396.015555 355.599039 93.578695 23.16439
5 2018-04 4806.727348 -9305.278306 14112.005654 360.263313 324.235141 85.325037 14.824819
6 2018-05 5511.866602 -9401.571826 14913.438427 370.694857 336.357109 88.515029 16.386948
7 2018-06 4846.162293 -8254.164575 13100.326868 344.023224 311.38346 81.943016 15.563326
8 2018-07 4733.333558 -10028.827592 14762.16115 352.885749 316.674389 83.335365 14.947005
9 2018-08 5369.799211 -10320.611101 15690.410312 325.781443 295.822752 77.848093 18.152083
10 2018-09 6294.844593 -8947.096603 15241.941196 306.195008 275.438495 72.483814 22.853903
11 2018-10 5998.14371 -7374.223128 13372.366838 305.310416 276.445151 72.748724 21.69741
12 2018-11 5658.950526 -9976.708879 15635.659404 308.559422 276.514059 72.766858 20.465327
13 2018-12 5227.858471 -10580.168315 15808.026786 292.344937 265.802125 69.947928 19.668234
14 2019-01 4846.091122 -9716.467684 14562.558806 301.096315 269.934425 71.035375 17.952846
15 2019-02 4386.850007 -9169.974804 13556.824811 354.872114 322.077084 84.757127 13.620497
16 2019-03 5685.537534 -9202.12936 14887.666893 395.787504 355.393223 93.524532 15.997878
17 2019-04 4718.922967 -9321.77924 14040.702207 366.770343 330.107734 86.870456 14.2951
18 2019-05 5427.961092 -9266.13638 14694.097472 424.406564 385.597895 101.47313 14.076739
19 2019-06 5106.284583 -8953.112834 14059.397417 431.412168 389.48601 102.496318 13.110316
20 2019-07 4410.738629 -10309.250217 14719.988846 455.116658 407.132784 107.140206 10.833661
21 2019-08 5371.192114 -7627.944384 12999.136498 411.497847 373.181806 98.205739 14.392963
22 2019-09 6108.180331 -6121.834754 12230.015085 446.080294 404.392466 106.41907 15.104585
23 2019-10 6225.554888 -7392.268463 13617.823351 460.993925 414.125371 108.980361 15.03302
24 2019-11 5546.450256 -9064.889778 14611.340034 417.26031 374.889076 98.65502 14.79491
25 2019-12 6465.947275 -7738.797076 14204.744351 478.917865 433.125873 113.980493 14.928564
26 2020-01 5752.783129 -6748.3924 12501.175529 469.180307 424.045227 111.590849 13.566438
27 2020-02 6045.669316 -5597.409405 11643.07872 446.676521 405.028061 106.586332 14.926544
28 2020-03 6146.118796 -6819.499809 12965.618605 494.105284 446.125018 117.401321 13.776674
29 2020-04 5671.636666 -5544.659957 11216.296623 482.833775 435.757483 114.673022 13.015581
30 2020-05 5591.88205 -5609.8907 11201.77275 518.380706 467.838587 123.115418 11.952588
31 2020-06 5305.6492 -5883.53861 11189.18781 491.564836 442.734765 116.509149 11.98381
32 2020-07 5152.114341 -6940.203845 12092.318187 513.960584 463.849427 122.065639 11.107299
33 2020-08 4740.600521 -9184.278367 13924.878888 479.188819 431.491892 113.550498 10.986534
34 2020-09 7591.825678 -6986.500821 14578.326499 397.581913 357.918912 94.189187 21.211021
35 2020-10 7291.309946 -7303.097011 14594.406957 443.701444 402.315335 105.872457 18.123371
36 2020-11 7836.211148 -5352.907107 13189.118256 436.922129 391.614722 103.056506 20.010001
37 2020-12 9205.886933 -6449.732679 15655.619612 387.71957 353.526912 93.033398 26.040131
+64
View File
@@ -0,0 +1,64 @@
{
"solver": {
"commit_hours": 24,
"degradation_cost_gbp_per_mwh": 0.0,
"milp_fallbacks": 1096,
"mode": "auto",
"window_hours": 48,
"windows": 1096
},
"summary": {
"average_captured_spread_gbp_per_mwh": 15.816025,
"capacity_end_mwh": 3.861279,
"capacity_retained_fraction": 0.96532,
"capacity_start_mwh": 4.0,
"charged_market_1_mwh": 11297.137749,
"charged_market_2_mwh": 3305.034144,
"cycle_budget": 5000.0,
"cycle_budget_used_fraction": 0.693603,
"days": 1096.0,
"discharged_market_1_mwh": 1952.341071,
"discharged_market_2_mwh": 11226.119067,
"end": "2020-12-31 23:30:00",
"energy_charged_from_grid_mwh": 14602.171893,
"energy_discharged_to_grid_mwh": 13178.460138,
"equivalent_full_cycles": 3468.015826,
"equivalent_full_cycles_per_year": 1155.741588,
"fixed_opex_gbp": 15003.422313,
"gross_profit_per_year_gbp": 69461.10309,
"gross_trading_profit_gbp": 208430.852803,
"half_hours": 52608,
"implied_life_years": 4.326227,
"net_of_opex_gbp": 193427.430489,
"profit_market_1_gbp": -297418.057114,
"profit_market_2_gbp": 505848.909916,
"profit_share_market_1": -1.426939,
"profit_share_market_2": 2.426939,
"round_trip_efficiency": 0.9025,
"start": "2018-01-01 00:00:00"
},
"validation": {
"checks": {
"charge_within_limit": true,
"discharge_within_limit": true,
"hourly_commitment_constant": true,
"no_simultaneous_charge_discharge": true,
"revenue_matches_prices_and_powers": true,
"soc_matches_power_flows": true,
"soc_within_bounds": true
},
"details": {
"hours_checked": 26304.0,
"max_hourly_commitment_gap_mw": 0.0,
"max_revenue_error_gbp": 0.0,
"max_soc_above_capacity_mwh": 0.000413531614,
"max_soc_below_zero_mwh": 5.101628e-06,
"max_soc_drift_mwh": 5.14322e-06,
"max_total_charge_mw": 2.00000005,
"max_total_discharge_mw": 2.0000001,
"simultaneous_half_hours": 0.0
},
"failures": [],
"ok": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

+2 -1
View File
@@ -72,7 +72,8 @@ def _format_summary(summary: dict[str, float | int | str], annual: pd.DataFrame)
f" Market 2 £{summary['profit_market_2_gbp']:,.2f}"
f" ({summary['profit_share_market_2']:.1%})",
f"Per year £{summary['gross_profit_per_year_gbp']:,.2f}",
f"Less fixed opex £{summary['net_of_opex_gbp']:,.2f}",
f"Fixed opex £{summary['fixed_opex_gbp']:,.2f}",
f"Net of opex £{summary['net_of_opex_gbp']:,.2f}",
f"Energy discharged {summary['energy_discharged_to_grid_mwh']:,.1f} MWh"
f" (charged {summary['energy_charged_from_grid_mwh']:,.1f} MWh)",
f"Captured spread "
+27 -7
View File
@@ -167,26 +167,46 @@ def plot_week(
def plot_monthly_revenue(monthly: pd.DataFrame, out_dir: Path) -> Path:
"""Monthly gross profit, stacked by market."""
"""Monthly gross profit, stacked by market.
Market 1 runs a persistent loss here (it is used mainly as a cheap import
source; Market 2 is where the exports are sold -- see RESULTS.md) while
Market 2 is consistently positive. A naive running ``bottom`` -- correct
for same-signed stacks -- makes the positive series' bar completely
overlap and paint over the negative one, hiding it entirely. Accumulating
positive and negative contributions on separate baselines keeps every
segment visible regardless of sign.
"""
fig, ax = _new_figure(1, (12, 5))
index = np.arange(len(monthly))
bottom = np.zeros(len(monthly))
bottom_pos = np.zeros(len(monthly))
bottom_neg = np.zeros(len(monthly))
net = np.zeros(len(monthly))
for name in (MARKET_1, MARKET_2):
values = monthly[f"profit_{name}_gbp"].to_numpy()
positive = np.clip(values, 0, None)
negative = np.clip(values, None, 0)
ax.bar(
index, values, bottom=bottom, width=0.78, color=COLOUR[name],
label=LABEL[name],
# A 2px surface-coloured edge separates the stacked segments.
index, positive, bottom=bottom_pos, width=0.78, color=COLOUR[name],
label=LABEL[name], edgecolor=SURFACE, linewidth=2,
)
ax.bar(
index, negative, bottom=bottom_neg, width=0.78, color=COLOUR[name],
edgecolor=SURFACE, linewidth=2,
)
bottom += values
bottom_pos += positive
bottom_neg += negative
net += values
ax.scatter(index, net, color=INK, s=14, zorder=3, label="Net")
ax.axhline(0, color=INK_SECONDARY, linewidth=1)
_style_axes(ax, "Gross profit")
ax.yaxis.set_major_formatter(FuncFormatter(_money))
ax.set_xticks(index[::3])
ax.set_xticklabels(monthly.index[::3], rotation=45, ha="right")
ax.legend(frameon=False, fontsize=9, labelcolor=INK, ncol=2, loc="upper left")
ax.legend(frameon=False, fontsize=9, labelcolor=INK, ncol=3, loc="upper left")
ax.set_title(
"Monthly gross trading profit by market", color=INK, fontsize=13,
loc="left", pad=12,
+7 -1
View File
@@ -178,9 +178,15 @@ def _check_energy_balance(
above = float((recomputed - capacity).max(initial=0.0))
report.details["max_soc_below_zero_mwh"] = max(0.0, below)
report.details["max_soc_above_capacity_mwh"] = max(0.0, above)
# `below` comes from the same cumulative sum as `drift` above, so it is
# subject to the same solver-precision accumulation over a long run and
# gets the same sqrt(n)-scaled tolerance; `above` has a different, already-
# bounded cause (the window-seam capacity fade noted above) and keeps its
# flat tolerance regardless of run length.
report.record(
"soc_within_bounds",
below <= ENERGY_TOLERANCE_MWH and above <= CAPACITY_SEAM_TOLERANCE_MWH,
below <= ENERGY_TOLERANCE_MWH * max(1.0, len(result) ** 0.5)
and above <= CAPACITY_SEAM_TOLERANCE_MWH,
f"state of charge leaves [0, capacity] by up to "
f"{max(below, above):.9f} MWh",
)