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:
@@ -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
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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:
|
||||
|
||||

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

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

|
||||
|
||||
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.)
|
||||
@@ -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
|
||||
|
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
+52609
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -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
|
||||
|
@@ -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 |
@@ -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 "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user