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.
Files changed (58) hide show
  1. openenergy/__init__.py +20 -0
  2. openenergy/app/__init__.py +9 -0
  3. openenergy/app/pages.py +379 -0
  4. openenergy/app/results.py +176 -0
  5. openenergy/app/runs.py +190 -0
  6. openenergy/app/server.py +397 -0
  7. openenergy/assets/__init__.py +19 -0
  8. openenergy/assets/battery.py +184 -0
  9. openenergy/assets/renewable.py +53 -0
  10. openenergy/backtest/__init__.py +23 -0
  11. openenergy/backtest/engine.py +550 -0
  12. openenergy/backtest/response.py +147 -0
  13. openenergy/cli.py +489 -0
  14. openenergy/data/__init__.py +33 -0
  15. openenergy/data/carbon.py +90 -0
  16. openenergy/data/ember.py +109 -0
  17. openenergy/data/fes.py +85 -0
  18. openenergy/data/fleet.py +183 -0
  19. openenergy/data/interconnectors.py +118 -0
  20. openenergy/data/neso.py +71 -0
  21. openenergy/data/opsd.py +116 -0
  22. openenergy/data/quality.py +23 -0
  23. openenergy/data/response.py +151 -0
  24. openenergy/data/series.py +186 -0
  25. openenergy/data/update.py +102 -0
  26. openenergy/dispatch/__init__.py +20 -0
  27. openenergy/dispatch/_milp.py +85 -0
  28. openenergy/dispatch/arbitrage.py +331 -0
  29. openenergy/dispatch/colocated.py +209 -0
  30. openenergy/errors.py +21 -0
  31. openenergy/forecast/__init__.py +10 -0
  32. openenergy/forecast/baseline.py +77 -0
  33. openenergy/forecast/ml.py +96 -0
  34. openenergy/metrics/__init__.py +6 -0
  35. openenergy/metrics/capture.py +80 -0
  36. openenergy/metrics/summary.py +101 -0
  37. openenergy/py.typed +0 -0
  38. openenergy/report/__init__.py +21 -0
  39. openenergy/report/document.py +294 -0
  40. openenergy/report/figures.py +219 -0
  41. openenergy/report/load.py +126 -0
  42. openenergy/scenario.py +540 -0
  43. openenergy/system/__init__.py +49 -0
  44. openenergy/system/_pypsa.py +161 -0
  45. openenergy/system/commitment.py +364 -0
  46. openenergy/system/fleet.py +205 -0
  47. openenergy/system/forward.py +143 -0
  48. openenergy/system/merit.py +59 -0
  49. openenergy/system/model.py +251 -0
  50. openenergy/system/netload.py +157 -0
  51. openenergy/system/scenario.py +475 -0
  52. openenergy/system/storage.py +120 -0
  53. openenergy/system/validate.py +227 -0
  54. openenergy-4.1.0.dist-info/METADATA +312 -0
  55. openenergy-4.1.0.dist-info/RECORD +58 -0
  56. openenergy-4.1.0.dist-info/WHEEL +4 -0
  57. openenergy-4.1.0.dist-info/entry_points.txt +2 -0
  58. 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"]
@@ -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)