openenergy 4.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openenergy/__init__.py +20 -0
- openenergy/app/__init__.py +9 -0
- openenergy/app/pages.py +379 -0
- openenergy/app/results.py +176 -0
- openenergy/app/runs.py +190 -0
- openenergy/app/server.py +397 -0
- openenergy/assets/__init__.py +19 -0
- openenergy/assets/battery.py +184 -0
- openenergy/assets/renewable.py +53 -0
- openenergy/backtest/__init__.py +23 -0
- openenergy/backtest/engine.py +550 -0
- openenergy/backtest/response.py +147 -0
- openenergy/cli.py +489 -0
- openenergy/data/__init__.py +33 -0
- openenergy/data/carbon.py +90 -0
- openenergy/data/ember.py +109 -0
- openenergy/data/fes.py +85 -0
- openenergy/data/fleet.py +183 -0
- openenergy/data/interconnectors.py +118 -0
- openenergy/data/neso.py +71 -0
- openenergy/data/opsd.py +116 -0
- openenergy/data/quality.py +23 -0
- openenergy/data/response.py +151 -0
- openenergy/data/series.py +186 -0
- openenergy/data/update.py +102 -0
- openenergy/dispatch/__init__.py +20 -0
- openenergy/dispatch/_milp.py +85 -0
- openenergy/dispatch/arbitrage.py +331 -0
- openenergy/dispatch/colocated.py +209 -0
- openenergy/errors.py +21 -0
- openenergy/forecast/__init__.py +10 -0
- openenergy/forecast/baseline.py +77 -0
- openenergy/forecast/ml.py +96 -0
- openenergy/metrics/__init__.py +6 -0
- openenergy/metrics/capture.py +80 -0
- openenergy/metrics/summary.py +101 -0
- openenergy/py.typed +0 -0
- openenergy/report/__init__.py +21 -0
- openenergy/report/document.py +294 -0
- openenergy/report/figures.py +219 -0
- openenergy/report/load.py +126 -0
- openenergy/scenario.py +540 -0
- openenergy/system/__init__.py +49 -0
- openenergy/system/_pypsa.py +161 -0
- openenergy/system/commitment.py +364 -0
- openenergy/system/fleet.py +205 -0
- openenergy/system/forward.py +143 -0
- openenergy/system/merit.py +59 -0
- openenergy/system/model.py +251 -0
- openenergy/system/netload.py +157 -0
- openenergy/system/scenario.py +475 -0
- openenergy/system/storage.py +120 -0
- openenergy/system/validate.py +227 -0
- openenergy-4.1.0.dist-info/METADATA +312 -0
- openenergy-4.1.0.dist-info/RECORD +58 -0
- openenergy-4.1.0.dist-info/WHEEL +4 -0
- openenergy-4.1.0.dist-info/entry_points.txt +2 -0
- openenergy-4.1.0.dist-info/licenses/LICENSE.txt +21 -0
openenergy/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Backtest battery storage and renewables against real power-market data."""
|
|
2
|
+
|
|
3
|
+
from openenergy.errors import (
|
|
4
|
+
ConfigError,
|
|
5
|
+
DataError,
|
|
6
|
+
DispatchError,
|
|
7
|
+
InfeasibleDispatchError,
|
|
8
|
+
OpenEnergyError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__version__ = "4.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ConfigError",
|
|
15
|
+
"DataError",
|
|
16
|
+
"DispatchError",
|
|
17
|
+
"InfeasibleDispatchError",
|
|
18
|
+
"OpenEnergyError",
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Web app: value a battery on historic GB prices or on a NESO FES pathway's modelled prices.
|
|
2
|
+
|
|
3
|
+
Run locally with ``uv run uvicorn openenergy.app:app``; Vercel serves ``app.py`` at the
|
|
4
|
+
repository root. Data are read from ``$OPENENERGY_DATA`` or ``./data``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from openenergy.app.server import app
|
|
8
|
+
|
|
9
|
+
__all__ = ["app"]
|
openenergy/app/pages.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""HTML for the app's result page: the answer first, then charts, assumptions and downloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import math
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import plotly.graph_objects as go
|
|
11
|
+
import yaml
|
|
12
|
+
from plotly.offline import get_plotlyjs_version
|
|
13
|
+
|
|
14
|
+
from openenergy.app.results import PRECOMPUTED_HOURS, Result, Store
|
|
15
|
+
from openenergy.app.runs import (
|
|
16
|
+
DEGRADATION_COST,
|
|
17
|
+
SCENARIO_ATTRIBUTION,
|
|
18
|
+
SERVICES,
|
|
19
|
+
WEATHER_YEAR,
|
|
20
|
+
Request,
|
|
21
|
+
)
|
|
22
|
+
from openenergy.data.ember import EMBER_ATTRIBUTION
|
|
23
|
+
from openenergy.data.response import RESPONSE_ATTRIBUTION
|
|
24
|
+
from openenergy.report import figures
|
|
25
|
+
|
|
26
|
+
PLOTLY_JS = f"https://cdn.plot.ly/plotly-{get_plotlyjs_version()}.min.js"
|
|
27
|
+
SERVICE_NAMES = {
|
|
28
|
+
"dc": "Dynamic Containment",
|
|
29
|
+
"dc_dm": "Dynamic Containment and Moderation",
|
|
30
|
+
}
|
|
31
|
+
MODEL_SPREAD_SHARE = 0.6 # modelled 2025 daily spread / actual (forward scenarios guide)
|
|
32
|
+
HELP = {
|
|
33
|
+
"power": "The most the battery can charge or discharge at once. Revenue scales with it.",
|
|
34
|
+
"duration": "Hours to fill or empty at full power: a 10 MW, 2-hour battery stores 20 MWh.",
|
|
35
|
+
"forecast": (
|
|
36
|
+
"Last week's prices: each day is planned as if prices repeat the same day last "
|
|
37
|
+
"week, a simple forecast anyone could make. Perfect foresight: planned knowing "
|
|
38
|
+
"every price, the most the battery could earn."
|
|
39
|
+
),
|
|
40
|
+
"response": (
|
|
41
|
+
"NESO pays batteries to hold power ready for when grid frequency drifts from 50 Hz: "
|
|
42
|
+
"Dynamic Containment reacts within a second, Dynamic Moderation keeps frequency "
|
|
43
|
+
"near 50 Hz. Auction prices exist from late 2021 (Moderation from March 2022)."
|
|
44
|
+
),
|
|
45
|
+
"pathway": (
|
|
46
|
+
"NESO's Future Energy Scenarios describe GB's power plants and demand to 2050. "
|
|
47
|
+
"Prices here are OpenEnergy's model of that system on 2025's weather: not a forecast."
|
|
48
|
+
),
|
|
49
|
+
"perfect": "What the same battery earns knowing every price in advance: an upper bound.",
|
|
50
|
+
"share": "This plan's revenue as a share of perfect foresight on the same days.",
|
|
51
|
+
"cycles": "Full charge-and-discharge cycles a day; more cycling wears a battery faster.",
|
|
52
|
+
"spread": "Average price received when selling minus paid when buying, per MWh sold.",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def tip(name: str) -> str:
|
|
57
|
+
"""A focusable "?" that shows the explanation on hover, focus or tap."""
|
|
58
|
+
text = html.escape(HELP[name])
|
|
59
|
+
return (
|
|
60
|
+
f'<span class="tip" tabindex="0" role="note" aria-label="{text}" data-tip="{text}">?</span>'
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def query(request: Request, power_mw: float) -> str:
|
|
65
|
+
"""URL parameters that reproduce ``request`` for ``power_mw``."""
|
|
66
|
+
params: dict[str, str | float | int] = {
|
|
67
|
+
"source": request.source,
|
|
68
|
+
"hours": f"{request.hours:g}",
|
|
69
|
+
"forecast": request.forecast,
|
|
70
|
+
"services": request.services,
|
|
71
|
+
"power_mw": f"{power_mw:g}",
|
|
72
|
+
}
|
|
73
|
+
if request.source == "history":
|
|
74
|
+
params["year"] = request.year
|
|
75
|
+
else:
|
|
76
|
+
params["pathway"] = str(request.pathway)
|
|
77
|
+
params["target"] = request.year
|
|
78
|
+
return urlencode(params)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def answer(result: Result, power_mw: float) -> str:
|
|
82
|
+
"""One plain sentence with the result, for anyone."""
|
|
83
|
+
r, s = result.request, result.summary
|
|
84
|
+
battery = f"A {power_mw:g} MW, {r.hours:g}-hour battery"
|
|
85
|
+
if r.source == "history":
|
|
86
|
+
where = f"trading GB day-ahead prices in {r.year}"
|
|
87
|
+
if r.services != "none":
|
|
88
|
+
where += f" and selling {SERVICE_NAMES[r.services]}"
|
|
89
|
+
verb = "would have earned"
|
|
90
|
+
else:
|
|
91
|
+
where = f"on modelled GB prices for NESO's {r.pathway} pathway in {r.year}"
|
|
92
|
+
verb = "would earn"
|
|
93
|
+
total = s["revenue_per_mw_year"] * power_mw
|
|
94
|
+
perfect = s["perfect_per_mw_year"] * power_mw
|
|
95
|
+
if r.forecast == "naive_last_week":
|
|
96
|
+
how = (
|
|
97
|
+
f"planning each day on last week's prices: {s['capture_ratio']:.0%} of the "
|
|
98
|
+
f"{_money(perfect)} perfect foresight allows"
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
how = "with perfect foresight of prices, an upper bound"
|
|
102
|
+
text = (
|
|
103
|
+
f"{battery} {where} {verb} about <strong>{_money(total)} a year</strong> "
|
|
104
|
+
f"({_money(s['revenue_per_mw_year'])} per MW), {how}. "
|
|
105
|
+
f"It cycles {s['cycles_per_day']:.1f} times a day on average."
|
|
106
|
+
)
|
|
107
|
+
if s["response_share"] > 0:
|
|
108
|
+
text += f" {s['response_share']:.0%} of the revenue comes from frequency response."
|
|
109
|
+
return text
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fragment(result: Result, power_mw: float) -> str:
|
|
113
|
+
"""The result block: answer, key numbers, charts, assumptions, downloads and sources."""
|
|
114
|
+
r, s = result.request, result.summary
|
|
115
|
+
params = html.escape(query(r, power_mw))
|
|
116
|
+
numbers = [
|
|
117
|
+
("Revenue per MW-year", _money(s["revenue_per_mw_year"]), ""),
|
|
118
|
+
("Perfect foresight per MW-year", _money(s["perfect_per_mw_year"]), "perfect"),
|
|
119
|
+
("Share of perfect foresight", f"{s['capture_ratio']:.0%}", "share"),
|
|
120
|
+
("Cycles per day", f"{s['cycles_per_day']:.2f}", "cycles"),
|
|
121
|
+
("Average spread captured", f"£{s['captured_spread']:.2f}/MWh", "spread"),
|
|
122
|
+
("Days simulated", f"{s['days']:.0f}", ""),
|
|
123
|
+
]
|
|
124
|
+
cards = "".join(
|
|
125
|
+
f'<div class="card"><div class="value">{value}</div><div>{name} '
|
|
126
|
+
f"{tip(help_key) if help_key else ''}</div></div>"
|
|
127
|
+
for name, value, help_key in numbers
|
|
128
|
+
)
|
|
129
|
+
daily = result.daily.set_axis(pd.to_datetime(result.daily.index)) * power_mw
|
|
130
|
+
charts = [
|
|
131
|
+
figures.cumulative_revenue(
|
|
132
|
+
{"this plan": daily["revenue"], "perfect foresight": daily["benchmark_revenue"]}
|
|
133
|
+
),
|
|
134
|
+
figures.monthly_revenue(daily["revenue"]),
|
|
135
|
+
]
|
|
136
|
+
plots = "".join(_plot(fig) for fig in charts)
|
|
137
|
+
assumptions = "".join(f"<li>{html.escape(item)}</li>" for item in _assumptions(r, s))
|
|
138
|
+
more = ""
|
|
139
|
+
if r.hours in PRECOMPUTED_HOURS:
|
|
140
|
+
shared = {"hours": f"{r.hours:g}", "forecast": r.forecast, "power_mw": f"{power_mw:g}"}
|
|
141
|
+
explore = urlencode({**shared, "services": r.services})
|
|
142
|
+
compare = urlencode({**shared, "item": r.prices})
|
|
143
|
+
more = (
|
|
144
|
+
f'<a href="/explore?{html.escape(explore)}">Every year and pathway</a>'
|
|
145
|
+
f'<a href="/compare?{html.escape(compare)}">Compare with others</a>'
|
|
146
|
+
)
|
|
147
|
+
return (
|
|
148
|
+
f'<p class="answer">{answer(result, power_mw)}</p>'
|
|
149
|
+
f'<div class="cards">{cards}</div>{plots}'
|
|
150
|
+
f"<details open><summary>What this assumes</summary><ul>{assumptions}</ul></details>"
|
|
151
|
+
f'<p class="actions">{more}'
|
|
152
|
+
f'<a href="/download/daily.csv?{params}">Daily revenue (CSV)</a>'
|
|
153
|
+
f'<a href="/download/scenario.yaml?{params}">Scenario to run yourself (YAML)</a>'
|
|
154
|
+
f'<a href="/battery?{params}">Full hourly report (runs now, 15 to 45 s)</a></p>'
|
|
155
|
+
f'<p class="source">{html.escape(_sources(r))}</p>'
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
_CLICK = (
|
|
160
|
+
"document.getElementById('{plot_id}').on('plotly_click', e => {"
|
|
161
|
+
" const url = e.points[0].customdata; if (url) window.location.href = url; });"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def explore(store: Store, hours: float, forecast: str, services: str, power_mw: float) -> str:
|
|
166
|
+
"""Value by GB year and by FES pathway for one battery, each bar opening its result."""
|
|
167
|
+
found = {r.key: store.get(r) for r in store.requests(hours, forecast)}
|
|
168
|
+
results = [result for result in found.values() if result is not None]
|
|
169
|
+
history = [
|
|
170
|
+
x for x in results if x.request.source == "history" and x.request.services == services
|
|
171
|
+
]
|
|
172
|
+
pathways = [x for x in results if x.request.source == "scenario"]
|
|
173
|
+
years = _bars(
|
|
174
|
+
[x.request.where for x in history],
|
|
175
|
+
{"this plan": history}
|
|
176
|
+
| ({"perfect foresight": history} if forecast == "naive_last_week" else {}),
|
|
177
|
+
power_mw,
|
|
178
|
+
"GB day-ahead prices by year",
|
|
179
|
+
)
|
|
180
|
+
by_target = {
|
|
181
|
+
str(target): [x for x in pathways if x.request.year == target]
|
|
182
|
+
for target in sorted({x.request.year for x in pathways})
|
|
183
|
+
}
|
|
184
|
+
names = [x.request.pathway or "" for x in next(iter(by_target.values()), [])]
|
|
185
|
+
forward = _bars(names, by_target, power_mw, "FES pathways (modelled prices)")
|
|
186
|
+
note = (
|
|
187
|
+
"<p>Frequency response is available on historic years only, so pathways show "
|
|
188
|
+
"trading alone.</p>"
|
|
189
|
+
if services != "none"
|
|
190
|
+
else ""
|
|
191
|
+
)
|
|
192
|
+
return (
|
|
193
|
+
f"{years}{_table(history, power_mw)}<h2>FES pathways {tip('pathway')}</h2>{note}"
|
|
194
|
+
f"{forward}{_table(pathways, power_mw)}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def compare(results: list[Result], power_mw: float) -> str:
|
|
199
|
+
"""Side by side: answer sentences, key numbers and revenue through the year."""
|
|
200
|
+
if not results:
|
|
201
|
+
return "<p>Choose up to three to compare.</p>"
|
|
202
|
+
fig = go.Figure()
|
|
203
|
+
for result in results:
|
|
204
|
+
cumulative = (result.daily["revenue"] * power_mw).cumsum()
|
|
205
|
+
fig.add_trace(
|
|
206
|
+
go.Scatter(
|
|
207
|
+
x=list(range(1, len(cumulative) + 1)),
|
|
208
|
+
y=cumulative.to_numpy(),
|
|
209
|
+
mode="lines",
|
|
210
|
+
name=result.request.where,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
fig.update_layout(
|
|
214
|
+
title="Revenue through the year (this plan)",
|
|
215
|
+
template=figures.TEMPLATE,
|
|
216
|
+
xaxis_title="Day simulated",
|
|
217
|
+
yaxis_title="Revenue (GBP)",
|
|
218
|
+
legend={"orientation": "h", "y": -0.2},
|
|
219
|
+
margin={"l": 60, "r": 20, "t": 50, "b": 40},
|
|
220
|
+
height=380,
|
|
221
|
+
)
|
|
222
|
+
answers = "".join(f'<p class="answer">{answer(x, power_mw)}</p>' for x in results)
|
|
223
|
+
return f"{_table(results, power_mw)}{_plot(fig)}{answers}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _bars(labels: list[str], series: dict[str, list[Result]], power_mw: float, title: str) -> str:
|
|
227
|
+
if not labels:
|
|
228
|
+
return "<p>No precomputed results for this choice.</p>"
|
|
229
|
+
fig = go.Figure()
|
|
230
|
+
for name, items in series.items():
|
|
231
|
+
field = "perfect_per_mw_year" if name == "perfect foresight" else "revenue_per_mw_year"
|
|
232
|
+
fig.add_trace(
|
|
233
|
+
go.Bar(
|
|
234
|
+
x=labels,
|
|
235
|
+
y=[x.summary[field] * power_mw for x in items],
|
|
236
|
+
name=name,
|
|
237
|
+
customdata=[f"/result?{query(x.request, power_mw)}" for x in items],
|
|
238
|
+
hovertemplate="%{x}: £%{y:,.0f} a year<extra>" + name + "</extra>",
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
fig.update_layout(
|
|
242
|
+
title=f"{title} (click a bar to open it)",
|
|
243
|
+
template=figures.TEMPLATE,
|
|
244
|
+
yaxis_title=f"Revenue for {power_mw:g} MW (GBP a year)",
|
|
245
|
+
barmode="group",
|
|
246
|
+
legend={"orientation": "h", "y": -0.2},
|
|
247
|
+
margin={"l": 60, "r": 20, "t": 50, "b": 40},
|
|
248
|
+
height=380,
|
|
249
|
+
showlegend=len(series) > 1,
|
|
250
|
+
)
|
|
251
|
+
return _plot(fig, post_script=_CLICK)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _table(results: list[Result], power_mw: float) -> str:
|
|
255
|
+
if not results:
|
|
256
|
+
return ""
|
|
257
|
+
header = (
|
|
258
|
+
f"<tr><th>Prices</th><th>Revenue for {power_mw:g} MW</th><th>Per MW</th>"
|
|
259
|
+
f"<th>Share of perfect foresight {tip('share')}</th>"
|
|
260
|
+
f"<th>Cycles a day {tip('cycles')}</th><th></th></tr>"
|
|
261
|
+
)
|
|
262
|
+
rows = "".join(
|
|
263
|
+
f"<tr><td>{html.escape(x.request.where)}</td>"
|
|
264
|
+
f"<td>{_money(x.summary['revenue_per_mw_year'] * power_mw)}</td>"
|
|
265
|
+
f"<td>{_money(x.summary['revenue_per_mw_year'])}</td>"
|
|
266
|
+
f"<td>{x.summary['capture_ratio']:.0%}</td><td>{x.summary['cycles_per_day']:.1f}</td>"
|
|
267
|
+
f'<td><a href="/result?{html.escape(query(x.request, power_mw))}">Open</a></td></tr>'
|
|
268
|
+
for x in results
|
|
269
|
+
)
|
|
270
|
+
return f'<div class="wide"><table>{header}{rows}</table></div>'
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def daily_csv(result: Result, power_mw: float) -> str:
|
|
274
|
+
frame = (result.daily * power_mw).round(2)
|
|
275
|
+
frame.columns = ["revenue_gbp", "perfect_foresight_revenue_gbp"]
|
|
276
|
+
header = f"# {result.request.label}, {power_mw:g} MW. {_sources(result.request)}\n"
|
|
277
|
+
return header + frame.to_csv(index_label="date")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def scenario_yaml(request: Request, power_mw: float) -> str:
|
|
281
|
+
"""A scenario that reproduces the result with the command line, from the repo root."""
|
|
282
|
+
battery = {"power_mw": power_mw, "energy_mwh": power_mw * request.hours}
|
|
283
|
+
dispatch = {"degradation_cost": DEGRADATION_COST}
|
|
284
|
+
if request.source == "history":
|
|
285
|
+
raw: dict[str, object] = {
|
|
286
|
+
"name": f"app-gb-{request.year}",
|
|
287
|
+
"data": {
|
|
288
|
+
"source": "ember",
|
|
289
|
+
"path": "data/prices/gb_day_ahead_ember.csv",
|
|
290
|
+
"start": f"{request.year}-01-01",
|
|
291
|
+
"end": f"{request.year}-12-31",
|
|
292
|
+
},
|
|
293
|
+
"battery": battery,
|
|
294
|
+
"forecast": request.forecast,
|
|
295
|
+
"dispatch": dispatch,
|
|
296
|
+
}
|
|
297
|
+
if request.services != "none":
|
|
298
|
+
raw["response"] = {
|
|
299
|
+
"path": "data/response/gb_dynamic_response.csv",
|
|
300
|
+
"services": SERVICES[request.services],
|
|
301
|
+
}
|
|
302
|
+
command = "uv run openenergy run"
|
|
303
|
+
else:
|
|
304
|
+
raw = {
|
|
305
|
+
"name": f"app-{str(request.pathway).lower().replace(' ', '-')}-{request.year}",
|
|
306
|
+
"system": {
|
|
307
|
+
"data": "data/system",
|
|
308
|
+
"year": WEATHER_YEAR,
|
|
309
|
+
"interconnectors": "links",
|
|
310
|
+
"fes": {"pathway": request.pathway, "year": request.year},
|
|
311
|
+
},
|
|
312
|
+
"battery": battery,
|
|
313
|
+
"forecast": request.forecast,
|
|
314
|
+
"dispatch": dispatch,
|
|
315
|
+
}
|
|
316
|
+
command = "uv sync --extra system && uv run openenergy system run"
|
|
317
|
+
head = (
|
|
318
|
+
f"# {request.label}, from the OpenEnergy web app.\n"
|
|
319
|
+
f"# Save in the OpenEnergy folder as app.yaml and run: {command} app.yaml\n"
|
|
320
|
+
)
|
|
321
|
+
return head + yaml.safe_dump(raw, sort_keys=False)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _assumptions(request: Request, summary: dict[str, float]) -> list[str]:
|
|
325
|
+
items = [
|
|
326
|
+
"A price taker: the battery's trades do not move prices.",
|
|
327
|
+
"Each day is planned at noon the day before, then settled at the prices that cleared.",
|
|
328
|
+
f"95% efficiency each way, a £{DEGRADATION_COST:g}/MWh wear cost, no capacity fade.",
|
|
329
|
+
"Revenue scales with power at a fixed duration, so results are per MW times your MW.",
|
|
330
|
+
]
|
|
331
|
+
if request.services != "none":
|
|
332
|
+
items.append(
|
|
333
|
+
"Frequency response: every MW offered is accepted at the clearing price, delivery "
|
|
334
|
+
"is always available and never penalised, and the energy it moves is not paid."
|
|
335
|
+
)
|
|
336
|
+
if request.source == "scenario":
|
|
337
|
+
items.append(
|
|
338
|
+
f"Prices are OpenEnergy's model of GB with this pathway's fleet and demand on "
|
|
339
|
+
f"{WEATHER_YEAR}'s weather and fuel, carbon and neighbouring prices: not a "
|
|
340
|
+
f"forecast. Modelled daily spreads are about {MODEL_SPREAD_SHARE:.0%} of real ones, "
|
|
341
|
+
"so values are lower than on real prices."
|
|
342
|
+
)
|
|
343
|
+
if summary["days"] < 365:
|
|
344
|
+
why = ""
|
|
345
|
+
if request.source == "scenario" and request.forecast == "naive_last_week":
|
|
346
|
+
why = " (the first week has no last week to plan on)"
|
|
347
|
+
elif request.services == "dc_dm" and request.year == 2022:
|
|
348
|
+
why = " (Dynamic Moderation starts on 26 March 2022)"
|
|
349
|
+
items.append(f"{summary['days']:.0f} days simulated{why}, scaled to a year.")
|
|
350
|
+
return items
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _sources(request: Request) -> str:
|
|
354
|
+
if request.source == "scenario":
|
|
355
|
+
return f"Prices modelled by OpenEnergy from open data. {SCENARIO_ATTRIBUTION}"
|
|
356
|
+
text = EMBER_ATTRIBUTION
|
|
357
|
+
if request.services != "none":
|
|
358
|
+
text += " " + RESPONSE_ATTRIBUTION
|
|
359
|
+
return text
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _plot(fig: go.Figure, post_script: str | None = None) -> str:
|
|
363
|
+
return str(
|
|
364
|
+
fig.to_html(
|
|
365
|
+
full_html=False,
|
|
366
|
+
include_plotlyjs=False,
|
|
367
|
+
default_width="100%",
|
|
368
|
+
config={"displaylogo": False, "responsive": True},
|
|
369
|
+
post_script=post_script,
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _money(value: float) -> str:
|
|
375
|
+
"""Pounds to three significant figures, e.g. £29,000 or £6,300."""
|
|
376
|
+
if value == 0 or not math.isfinite(value):
|
|
377
|
+
return "£0"
|
|
378
|
+
magnitude = math.floor(math.log10(abs(value)))
|
|
379
|
+
return f"£{round(value, 2 - magnitude):,.0f}"
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Results for the app: a compact per-MW summary and daily revenue, precomputed or live."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from functools import cache
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from openenergy.app.runs import (
|
|
14
|
+
HISTORY_YEARS,
|
|
15
|
+
RESPONSE_FROM,
|
|
16
|
+
SCENARIO_YEARS,
|
|
17
|
+
Request,
|
|
18
|
+
run,
|
|
19
|
+
)
|
|
20
|
+
from openenergy.data.fes import PATHWAYS
|
|
21
|
+
from openenergy.errors import DataError
|
|
22
|
+
from openenergy.metrics.summary import DAYS_PER_YEAR
|
|
23
|
+
from openenergy.report.load import BatteryOutput
|
|
24
|
+
|
|
25
|
+
PRECOMPUTED_HOURS = (1.0, 2.0, 4.0)
|
|
26
|
+
SUMMARY_FILE = "app/summary.csv"
|
|
27
|
+
DAILY_FILE = "app/daily.csv"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, eq=False)
|
|
31
|
+
class Result:
|
|
32
|
+
"""One request's per-MW results; ``daily`` has ``revenue`` and ``benchmark_revenue``."""
|
|
33
|
+
|
|
34
|
+
request: Request
|
|
35
|
+
summary: dict[str, float]
|
|
36
|
+
daily: pd.DataFrame
|
|
37
|
+
precomputed: bool
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"key": self.request.key,
|
|
42
|
+
"label": self.request.label,
|
|
43
|
+
"precomputed": self.precomputed,
|
|
44
|
+
"summary": self.summary,
|
|
45
|
+
"daily": {
|
|
46
|
+
"date": list(self.daily.index),
|
|
47
|
+
"revenue": self.daily["revenue"].round(2).tolist(),
|
|
48
|
+
"benchmark_revenue": self.daily["benchmark_revenue"].round(2).tolist(),
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def grid() -> Iterator[Request]:
|
|
54
|
+
"""Every request the app precomputes."""
|
|
55
|
+
for hours in PRECOMPUTED_HOURS:
|
|
56
|
+
for forecast in ("naive_last_week", "perfect_foresight"):
|
|
57
|
+
for year in range(HISTORY_YEARS[0], HISTORY_YEARS[1] + 1):
|
|
58
|
+
yield Request("history", year, hours, forecast)
|
|
59
|
+
if year >= RESPONSE_FROM:
|
|
60
|
+
yield Request("history", year, hours, forecast, "dc")
|
|
61
|
+
yield Request("history", year, hours, forecast, "dc_dm")
|
|
62
|
+
for pathway in PATHWAYS:
|
|
63
|
+
for target in SCENARIO_YEARS:
|
|
64
|
+
yield Request("scenario", target, hours, forecast, pathway=pathway)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def from_output(request: Request, output: BatteryOutput, precomputed: bool) -> Result:
|
|
68
|
+
"""Per-MW summary and daily revenue from a 1 MW run."""
|
|
69
|
+
s = output.summary["summary"]
|
|
70
|
+
days = int(s["days"])
|
|
71
|
+
benchmark = output.benchmark_daily if output.benchmark_daily is not None else output.daily
|
|
72
|
+
daily = pd.DataFrame(
|
|
73
|
+
{
|
|
74
|
+
"revenue": output.daily["revenue"].to_numpy(),
|
|
75
|
+
"benchmark_revenue": benchmark["revenue"].reindex(output.daily.index).to_numpy(),
|
|
76
|
+
},
|
|
77
|
+
index=pd.to_datetime(output.daily.index).strftime("%Y-%m-%d"),
|
|
78
|
+
)
|
|
79
|
+
benchmark_total = float(daily["benchmark_revenue"].sum())
|
|
80
|
+
response = s.get("response_revenue")
|
|
81
|
+
summary = {
|
|
82
|
+
"days": days,
|
|
83
|
+
"revenue_per_mw_year": float(s["revenue_per_mw_year"]),
|
|
84
|
+
"perfect_per_mw_year": benchmark_total * DAYS_PER_YEAR / days,
|
|
85
|
+
"capture_ratio": float(s["capture_ratio"]),
|
|
86
|
+
"cycles_per_day": float(s["equivalent_cycles"]) / days,
|
|
87
|
+
"captured_spread": float(s["captured_spread"] or 0.0),
|
|
88
|
+
"forecast_mae": float(s["forecast_mae"]),
|
|
89
|
+
"response_share": float(response) / float(s["revenue"]) if response else 0.0,
|
|
90
|
+
}
|
|
91
|
+
return Result(request, summary, daily, precomputed)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def live(request: Request, data: Path) -> Result:
|
|
95
|
+
return from_output(request, run(request, data), precomputed=False)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def write(results: list[Result], data: Path) -> None:
|
|
99
|
+
"""Write the precomputed summary and daily files under ``data``."""
|
|
100
|
+
rows, daily = [], []
|
|
101
|
+
for result in results:
|
|
102
|
+
r = result.request
|
|
103
|
+
rows.append(
|
|
104
|
+
{
|
|
105
|
+
"key": r.key,
|
|
106
|
+
"source": r.source,
|
|
107
|
+
"year": r.year,
|
|
108
|
+
"pathway": r.pathway or "",
|
|
109
|
+
"hours": r.hours,
|
|
110
|
+
"forecast": r.forecast,
|
|
111
|
+
"services": r.services,
|
|
112
|
+
**result.summary,
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
frame = result.daily.reset_index(names="date")
|
|
116
|
+
frame.insert(0, "key", r.key)
|
|
117
|
+
daily.append(frame)
|
|
118
|
+
(data / SUMMARY_FILE).parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
pd.DataFrame(rows).to_csv(data / SUMMARY_FILE, index=False, float_format="%.6g")
|
|
120
|
+
pd.concat(daily).to_csv(data / DAILY_FILE, index=False, float_format="%.2f")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Store:
|
|
124
|
+
"""The precomputed results, read once."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, data: Path) -> None:
|
|
127
|
+
summary_path, daily_path = data / SUMMARY_FILE, data / DAILY_FILE
|
|
128
|
+
if not summary_path.is_file() or not daily_path.is_file():
|
|
129
|
+
raise DataError("precomputed app results not found")
|
|
130
|
+
self.summary = pd.read_csv(summary_path, keep_default_na=False).set_index("key")
|
|
131
|
+
daily = pd.read_csv(daily_path)
|
|
132
|
+
self._daily = {key: frame.set_index("date") for key, frame in daily.groupby("key")}
|
|
133
|
+
|
|
134
|
+
def get(self, request: Request) -> Result | None:
|
|
135
|
+
if request.key not in self.summary.index:
|
|
136
|
+
return None
|
|
137
|
+
row = self.summary.loc[request.key].to_dict()
|
|
138
|
+
summary = {name: float(row[name]) for name in SUMMARY_FIELDS}
|
|
139
|
+
summary["days"] = int(row["days"])
|
|
140
|
+
frame = self._daily[request.key][["revenue", "benchmark_revenue"]]
|
|
141
|
+
return Result(request, summary, frame, precomputed=True)
|
|
142
|
+
|
|
143
|
+
def requests(self, hours: float, forecast: str) -> list[Request]:
|
|
144
|
+
"""Precomputed requests for one duration and forecast, in grid order."""
|
|
145
|
+
return [
|
|
146
|
+
r
|
|
147
|
+
for r in grid()
|
|
148
|
+
if r.hours == hours and r.forecast == forecast and r.key in self.summary.index
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
SUMMARY_FIELDS = (
|
|
153
|
+
"days",
|
|
154
|
+
"revenue_per_mw_year",
|
|
155
|
+
"perfect_per_mw_year",
|
|
156
|
+
"capture_ratio",
|
|
157
|
+
"cycles_per_day",
|
|
158
|
+
"captured_spread",
|
|
159
|
+
"forecast_mae",
|
|
160
|
+
"response_share",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@cache
|
|
165
|
+
def store(data: Path) -> Store | None:
|
|
166
|
+
try:
|
|
167
|
+
return Store(data)
|
|
168
|
+
except DataError:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def result(request: Request, data: Path) -> Result:
|
|
173
|
+
"""The precomputed result if there is one, else a live run."""
|
|
174
|
+
found = store(data)
|
|
175
|
+
cached = found.get(request) if found is not None else None
|
|
176
|
+
return cached if cached is not None else live(request, data)
|