Spaces:
Running
Running
Sync from GitHub 1db426b
Browse files- app.py +2 -54
- orchestration/airflow/dags/gridpulse_daily.py +0 -1
- orchestration/dagster_app/definitions.py +4 -41
- scripts/update_readme.py +0 -2
- src/gridpulse/agent/text2sql.py +5 -37
- src/gridpulse/api/main.py +1 -16
- src/gridpulse/cli.py +3 -21
- src/gridpulse/config.py +8 -29
- src/gridpulse/features/build.py +10 -80
- src/gridpulse/ingestion/eia.py +4 -38
- src/gridpulse/ingestion/http.py +1 -9
- src/gridpulse/ingestion/weather.py +6 -24
- src/gridpulse/models/anomaly.py +8 -66
- src/gridpulse/models/baselines.py +1 -19
- src/gridpulse/models/deep.py +15 -94
- src/gridpulse/models/gbm.py +3 -50
- src/gridpulse/models/inference.py +7 -30
- src/gridpulse/models/metrics.py +2 -18
- src/gridpulse/models/pipeline.py +4 -49
- src/gridpulse/quality/checks.py +10 -41
- src/gridpulse/warehouse/build.py +5 -66
- src/gridpulse/warehouse/duck.py +2 -10
- src/gridpulse/warehouse/export.py +4 -19
- tests/conftest.py +0 -1
- tests/test_features.py +0 -1
- tests/test_metrics.py +2 -3
- tests/test_quality.py +0 -1
- tests/test_style.py +0 -8
- tests/test_warehouse.py +0 -1
app.py
CHANGED
|
@@ -1,10 +1,5 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Runs locally or on Streamlit Community Cloud from the same file. The app reads the
|
| 4 |
-
slim DuckDB artifact committed alongside it and the pre-trained model files, so it
|
| 5 |
-
starts instantly and never trains on the request path. Live weather is fetched on
|
| 6 |
-
demand to produce genuinely forward-looking forecasts.
|
| 7 |
-
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
@@ -23,9 +18,6 @@ sys.path.insert(0, str(ROOT / "src"))
|
|
| 23 |
from gridpulse.config import BALANCING_AUTHORITIES # noqa: E402
|
| 24 |
from gridpulse.warehouse.duck import connect # noqa: E402
|
| 25 |
|
| 26 |
-
# ---------------------------------------------------------------------------
|
| 27 |
-
# Page setup
|
| 28 |
-
# ---------------------------------------------------------------------------
|
| 29 |
st.set_page_config(
|
| 30 |
page_title="GridPulse | US Electricity Demand Intelligence",
|
| 31 |
page_icon="⚡",
|
|
@@ -61,9 +53,6 @@ st.markdown(
|
|
| 61 |
)
|
| 62 |
|
| 63 |
|
| 64 |
-
# ---------------------------------------------------------------------------
|
| 65 |
-
# Data access
|
| 66 |
-
# ---------------------------------------------------------------------------
|
| 67 |
def database_path() -> Path:
|
| 68 |
slim = ROOT / "data" / "gold" / "gridpulse_app.duckdb"
|
| 69 |
return slim if slim.exists() else ROOT / "data" / "gold" / "gridpulse.duckdb"
|
|
@@ -161,9 +150,6 @@ def data_ready() -> bool:
|
|
| 161 |
).empty
|
| 162 |
|
| 163 |
|
| 164 |
-
# ---------------------------------------------------------------------------
|
| 165 |
-
# Header
|
| 166 |
-
# ---------------------------------------------------------------------------
|
| 167 |
head = headline()
|
| 168 |
skill = head.get("skill_vs_eia_pct")
|
| 169 |
|
|
@@ -192,9 +178,6 @@ if not data_ready():
|
|
| 192 |
)
|
| 193 |
st.stop()
|
| 194 |
|
| 195 |
-
# ---------------------------------------------------------------------------
|
| 196 |
-
# Sidebar
|
| 197 |
-
# ---------------------------------------------------------------------------
|
| 198 |
with st.sidebar:
|
| 199 |
st.header("Controls")
|
| 200 |
bas = available_bas()
|
|
@@ -224,9 +207,6 @@ with st.sidebar:
|
|
| 224 |
"[GitHub repository](https://github.com/adwitiyashukla/gridpulse) · Data: US EIA + Open-Meteo"
|
| 225 |
)
|
| 226 |
|
| 227 |
-
# ---------------------------------------------------------------------------
|
| 228 |
-
# Headline metrics
|
| 229 |
-
# ---------------------------------------------------------------------------
|
| 230 |
c1, c2, c3, c4 = st.columns(4)
|
| 231 |
summary = run_query(
|
| 232 |
"""
|
|
@@ -252,9 +232,6 @@ tabs = st.tabs([
|
|
| 252 |
])
|
| 253 |
|
| 254 |
|
| 255 |
-
# ---------------------------------------------------------------------------
|
| 256 |
-
# Tab 1: Forecast
|
| 257 |
-
# ---------------------------------------------------------------------------
|
| 258 |
with tabs[0]:
|
| 259 |
st.subheader(f"24-hour demand forecast - {selected_ba}")
|
| 260 |
st.caption(
|
|
@@ -292,9 +269,6 @@ with tabs[0]:
|
|
| 292 |
for note in notes:
|
| 293 |
st.caption(f"↳ {note}")
|
| 294 |
|
| 295 |
-
# demand_clean_mwh excludes readings the warehouse flagged as physically
|
| 296 |
-
# implausible. The raw column deliberately retains them as evidence, but a
|
| 297 |
-
# telemetry fault should not be drawn as though it were real load.
|
| 298 |
history = run_query(
|
| 299 |
"""
|
| 300 |
SELECT period_utc, demand_clean_mwh AS demand_mwh
|
|
@@ -355,9 +329,6 @@ with tabs[0]:
|
|
| 355 |
st.info("Choose a balancing authority in the sidebar and press **Generate forecast**.")
|
| 356 |
|
| 357 |
|
| 358 |
-
# ---------------------------------------------------------------------------
|
| 359 |
-
# Tab 2: Explorer
|
| 360 |
-
# ---------------------------------------------------------------------------
|
| 361 |
with tabs[1]:
|
| 362 |
st.subheader(f"Historical explorer - {selected_ba}")
|
| 363 |
|
|
@@ -402,9 +373,6 @@ with tabs[1]:
|
|
| 402 |
labels={"temperature_2m": "Temperature (°C)", "demand_mwh": "Demand (MW)"},
|
| 403 |
)
|
| 404 |
|
| 405 |
-
# Binned median rather than a lowess fit. It needs no extra
|
| 406 |
-
# dependency, is robust to the outliers this data genuinely
|
| 407 |
-
# contains, and traces the V-curve more legibly than a smoother.
|
| 408 |
binned = (
|
| 409 |
scatter.assign(bin=(scatter["temperature_2m"] / 2).round() * 2)
|
| 410 |
.groupby("bin")["demand_mwh"]
|
|
@@ -465,9 +433,6 @@ with tabs[1]:
|
|
| 465 |
st.plotly_chart(figure, use_container_width=True)
|
| 466 |
|
| 467 |
|
| 468 |
-
# ---------------------------------------------------------------------------
|
| 469 |
-
# Tab 3: Leaderboard
|
| 470 |
-
# ---------------------------------------------------------------------------
|
| 471 |
with tabs[2]:
|
| 472 |
st.subheader("Model leaderboard")
|
| 473 |
st.caption(
|
|
@@ -495,10 +460,6 @@ with tabs[2]:
|
|
| 495 |
}
|
| 496 |
board["Model"] = board["model"].map(lambda m: pretty.get(m, m))
|
| 497 |
|
| 498 |
-
# Sorted worst-first so the best model lands at the top of a horizontal
|
| 499 |
-
# bar chart. Colour is applied per-bar via marker_color rather than
|
| 500 |
-
# plotly's `color=` argument: that argument splits the data into one
|
| 501 |
-
# trace per colour group, which silently destroys the sort order.
|
| 502 |
ordered = board.sort_values("mape_pct", ascending=False)
|
| 503 |
bar_colours = [
|
| 504 |
ACCENT_2 if model == "eia_official" else ACCENT
|
|
@@ -564,9 +525,6 @@ with tabs[2]:
|
|
| 564 |
st.plotly_chart(figure, use_container_width=True)
|
| 565 |
|
| 566 |
|
| 567 |
-
# ---------------------------------------------------------------------------
|
| 568 |
-
# Tab 4: Anomalies
|
| 569 |
-
# ---------------------------------------------------------------------------
|
| 570 |
with tabs[3]:
|
| 571 |
st.subheader("Anomaly monitor")
|
| 572 |
st.caption(
|
|
@@ -611,9 +569,6 @@ with tabs[3]:
|
|
| 611 |
st.dataframe(recent, use_container_width=True, hide_index=True, height=380)
|
| 612 |
|
| 613 |
|
| 614 |
-
# ---------------------------------------------------------------------------
|
| 615 |
-
# Tab 5: Data quality
|
| 616 |
-
# ---------------------------------------------------------------------------
|
| 617 |
with tabs[4]:
|
| 618 |
st.subheader("Data quality scorecard")
|
| 619 |
st.caption(
|
|
@@ -660,9 +615,6 @@ with tabs[4]:
|
|
| 660 |
)
|
| 661 |
|
| 662 |
|
| 663 |
-
# ---------------------------------------------------------------------------
|
| 664 |
-
# Tab 6: AI agent
|
| 665 |
-
# ---------------------------------------------------------------------------
|
| 666 |
with tabs[5]:
|
| 667 |
st.subheader("Ask the Grid")
|
| 668 |
st.caption(
|
|
@@ -707,7 +659,6 @@ with tabs[5]:
|
|
| 707 |
|
| 708 |
st.dataframe(answer.data, use_container_width=True, hide_index=True, height=380)
|
| 709 |
|
| 710 |
-
# Offer a chart when the shape obviously supports one.
|
| 711 |
numeric = answer.data.select_dtypes("number").columns.tolist()
|
| 712 |
if len(answer.data) > 1 and numeric:
|
| 713 |
label_columns = [c for c in answer.data.columns if c not in numeric]
|
|
@@ -724,9 +675,6 @@ with tabs[5]:
|
|
| 724 |
pass
|
| 725 |
|
| 726 |
|
| 727 |
-
# ---------------------------------------------------------------------------
|
| 728 |
-
# Tab 7: How it works
|
| 729 |
-
# ---------------------------------------------------------------------------
|
| 730 |
with tabs[6]:
|
| 731 |
st.subheader("How GridPulse works")
|
| 732 |
|
|
|
|
| 1 |
+
"""The public GridPulse website. Reads the committed database and model files, so
|
| 2 |
+
it starts instantly and never trains anything while someone is waiting."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
|
|
| 18 |
from gridpulse.config import BALANCING_AUTHORITIES # noqa: E402
|
| 19 |
from gridpulse.warehouse.duck import connect # noqa: E402
|
| 20 |
|
|
|
|
|
|
|
|
|
|
| 21 |
st.set_page_config(
|
| 22 |
page_title="GridPulse | US Electricity Demand Intelligence",
|
| 23 |
page_icon="⚡",
|
|
|
|
| 53 |
)
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
| 56 |
def database_path() -> Path:
|
| 57 |
slim = ROOT / "data" / "gold" / "gridpulse_app.duckdb"
|
| 58 |
return slim if slim.exists() else ROOT / "data" / "gold" / "gridpulse.duckdb"
|
|
|
|
| 150 |
).empty
|
| 151 |
|
| 152 |
|
|
|
|
|
|
|
|
|
|
| 153 |
head = headline()
|
| 154 |
skill = head.get("skill_vs_eia_pct")
|
| 155 |
|
|
|
|
| 178 |
)
|
| 179 |
st.stop()
|
| 180 |
|
|
|
|
|
|
|
|
|
|
| 181 |
with st.sidebar:
|
| 182 |
st.header("Controls")
|
| 183 |
bas = available_bas()
|
|
|
|
| 207 |
"[GitHub repository](https://github.com/adwitiyashukla/gridpulse) · Data: US EIA + Open-Meteo"
|
| 208 |
)
|
| 209 |
|
|
|
|
|
|
|
|
|
|
| 210 |
c1, c2, c3, c4 = st.columns(4)
|
| 211 |
summary = run_query(
|
| 212 |
"""
|
|
|
|
| 232 |
])
|
| 233 |
|
| 234 |
|
|
|
|
|
|
|
|
|
|
| 235 |
with tabs[0]:
|
| 236 |
st.subheader(f"24-hour demand forecast - {selected_ba}")
|
| 237 |
st.caption(
|
|
|
|
| 269 |
for note in notes:
|
| 270 |
st.caption(f"↳ {note}")
|
| 271 |
|
|
|
|
|
|
|
|
|
|
| 272 |
history = run_query(
|
| 273 |
"""
|
| 274 |
SELECT period_utc, demand_clean_mwh AS demand_mwh
|
|
|
|
| 329 |
st.info("Choose a balancing authority in the sidebar and press **Generate forecast**.")
|
| 330 |
|
| 331 |
|
|
|
|
|
|
|
|
|
|
| 332 |
with tabs[1]:
|
| 333 |
st.subheader(f"Historical explorer - {selected_ba}")
|
| 334 |
|
|
|
|
| 373 |
labels={"temperature_2m": "Temperature (°C)", "demand_mwh": "Demand (MW)"},
|
| 374 |
)
|
| 375 |
|
|
|
|
|
|
|
|
|
|
| 376 |
binned = (
|
| 377 |
scatter.assign(bin=(scatter["temperature_2m"] / 2).round() * 2)
|
| 378 |
.groupby("bin")["demand_mwh"]
|
|
|
|
| 433 |
st.plotly_chart(figure, use_container_width=True)
|
| 434 |
|
| 435 |
|
|
|
|
|
|
|
|
|
|
| 436 |
with tabs[2]:
|
| 437 |
st.subheader("Model leaderboard")
|
| 438 |
st.caption(
|
|
|
|
| 460 |
}
|
| 461 |
board["Model"] = board["model"].map(lambda m: pretty.get(m, m))
|
| 462 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
ordered = board.sort_values("mape_pct", ascending=False)
|
| 464 |
bar_colours = [
|
| 465 |
ACCENT_2 if model == "eia_official" else ACCENT
|
|
|
|
| 525 |
st.plotly_chart(figure, use_container_width=True)
|
| 526 |
|
| 527 |
|
|
|
|
|
|
|
|
|
|
| 528 |
with tabs[3]:
|
| 529 |
st.subheader("Anomaly monitor")
|
| 530 |
st.caption(
|
|
|
|
| 569 |
st.dataframe(recent, use_container_width=True, hide_index=True, height=380)
|
| 570 |
|
| 571 |
|
|
|
|
|
|
|
|
|
|
| 572 |
with tabs[4]:
|
| 573 |
st.subheader("Data quality scorecard")
|
| 574 |
st.caption(
|
|
|
|
| 615 |
)
|
| 616 |
|
| 617 |
|
|
|
|
|
|
|
|
|
|
| 618 |
with tabs[5]:
|
| 619 |
st.subheader("Ask the Grid")
|
| 620 |
st.caption(
|
|
|
|
| 659 |
|
| 660 |
st.dataframe(answer.data, use_container_width=True, hide_index=True, height=380)
|
| 661 |
|
|
|
|
| 662 |
numeric = answer.data.select_dtypes("number").columns.tolist()
|
| 663 |
if len(answer.data) > 1 and numeric:
|
| 664 |
label_columns = [c for c in answer.data.columns if c not in numeric]
|
|
|
|
| 675 |
pass
|
| 676 |
|
| 677 |
|
|
|
|
|
|
|
|
|
|
| 678 |
with tabs[6]:
|
| 679 |
st.subheader("How GridPulse works")
|
| 680 |
|
orchestration/airflow/dags/gridpulse_daily.py
CHANGED
|
@@ -40,7 +40,6 @@ DEFAULT_ARGS = {
|
|
| 40 |
dag_id="gridpulse_daily_refresh",
|
| 41 |
description="Extract EIA and weather, rebuild the warehouse, validate, retrain, export.",
|
| 42 |
default_args=DEFAULT_ARGS,
|
| 43 |
-
# 06:00 UTC: EIA has published the previous full day by then.
|
| 44 |
schedule="0 6 * * *",
|
| 45 |
start_date=datetime(2024, 1, 1),
|
| 46 |
catchup=False,
|
|
|
|
| 40 |
dag_id="gridpulse_daily_refresh",
|
| 41 |
description="Extract EIA and weather, rebuild the warehouse, validate, retrain, export.",
|
| 42 |
default_args=DEFAULT_ARGS,
|
|
|
|
| 43 |
schedule="0 6 * * *",
|
| 44 |
start_date=datetime(2024, 1, 1),
|
| 45 |
catchup=False,
|
orchestration/dagster_app/definitions.py
CHANGED
|
@@ -1,24 +1,10 @@
|
|
| 1 |
-
"""The GridPulse pipeline
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
an actual picture of the warehouse, not just a schedule.
|
| 7 |
-
|
| 8 |
-
To run the UI locally::
|
| 9 |
-
|
| 10 |
-
dagster dev -f orchestration/dagster_app/definitions.py
|
| 11 |
-
|
| 12 |
-
There is an equivalent Airflow DAG in ``orchestration/airflow/dags/`` for anyone
|
| 13 |
-
whose team uses Airflow instead.
|
| 14 |
"""
|
| 15 |
|
| 16 |
-
# NOTE: do not add `from __future__ import annotations` to this file.
|
| 17 |
-
# It turns every type annotation into a string at runtime, and Dagster reads the
|
| 18 |
-
# annotation on the `context` argument to work out what to pass in. With that
|
| 19 |
-
# import it sees the text "AssetExecutionContext" instead of the actual class and
|
| 20 |
-
# throws DagsterInvalidDefinitionError, with an error message that confusingly
|
| 21 |
-
# names the exact type you already gave it.
|
| 22 |
|
| 23 |
import sys
|
| 24 |
from pathlib import Path
|
|
@@ -46,9 +32,6 @@ GROUP_ML = "04_machine_learning"
|
|
| 46 |
GROUP_SERVE = "05_serving"
|
| 47 |
|
| 48 |
|
| 49 |
-
# ---------------------------------------------------------------------------
|
| 50 |
-
# Extract
|
| 51 |
-
# ---------------------------------------------------------------------------
|
| 52 |
@asset(
|
| 53 |
group_name=GROUP_EXTRACT,
|
| 54 |
compute_kind="python",
|
|
@@ -89,9 +72,6 @@ def weather_bronze(context: AssetExecutionContext) -> Output[dict]:
|
|
| 89 |
)
|
| 90 |
|
| 91 |
|
| 92 |
-
# ---------------------------------------------------------------------------
|
| 93 |
-
# Warehouse
|
| 94 |
-
# ---------------------------------------------------------------------------
|
| 95 |
@asset(
|
| 96 |
group_name=GROUP_WAREHOUSE,
|
| 97 |
compute_kind="duckdb",
|
|
@@ -139,9 +119,6 @@ def dbt_marts(context: AssetExecutionContext) -> Output[str]:
|
|
| 139 |
)
|
| 140 |
|
| 141 |
|
| 142 |
-
# ---------------------------------------------------------------------------
|
| 143 |
-
# Quality
|
| 144 |
-
# ---------------------------------------------------------------------------
|
| 145 |
@asset(
|
| 146 |
group_name=GROUP_QUALITY,
|
| 147 |
compute_kind="python",
|
|
@@ -193,9 +170,6 @@ def benchmark_present() -> AssetCheckResult:
|
|
| 193 |
return AssetCheckResult(passed=rows > 1000, metadata={"benchmark_rows": rows})
|
| 194 |
|
| 195 |
|
| 196 |
-
# ---------------------------------------------------------------------------
|
| 197 |
-
# Machine learning
|
| 198 |
-
# ---------------------------------------------------------------------------
|
| 199 |
@asset(
|
| 200 |
group_name=GROUP_ML,
|
| 201 |
compute_kind="python",
|
|
@@ -247,9 +221,6 @@ def anomaly_scores(context: AssetExecutionContext) -> Output[dict]:
|
|
| 247 |
)
|
| 248 |
|
| 249 |
|
| 250 |
-
# ---------------------------------------------------------------------------
|
| 251 |
-
# Serving
|
| 252 |
-
# ---------------------------------------------------------------------------
|
| 253 |
@asset(
|
| 254 |
group_name=GROUP_SERVE,
|
| 255 |
compute_kind="python",
|
|
@@ -271,9 +242,6 @@ def app_export(context: AssetExecutionContext) -> Output[str]:
|
|
| 271 |
)
|
| 272 |
|
| 273 |
|
| 274 |
-
# ---------------------------------------------------------------------------
|
| 275 |
-
# Jobs and schedules
|
| 276 |
-
# ---------------------------------------------------------------------------
|
| 277 |
daily_refresh = define_asset_job(
|
| 278 |
name="daily_refresh",
|
| 279 |
selection=AssetSelection.all(),
|
|
@@ -286,7 +254,6 @@ incremental_refresh = define_asset_job(
|
|
| 286 |
description="Data-only refresh without retraining. Cheap enough to run hourly.",
|
| 287 |
)
|
| 288 |
|
| 289 |
-
# EIA publishes on a lag, so 06:00 UTC comfortably captures the previous full day.
|
| 290 |
daily_schedule = ScheduleDefinition(
|
| 291 |
job=daily_refresh,
|
| 292 |
cron_schedule="0 6 * * *",
|
|
@@ -294,10 +261,6 @@ daily_schedule = ScheduleDefinition(
|
|
| 294 |
description="Nightly full refresh after EIA publishes the previous day.",
|
| 295 |
)
|
| 296 |
|
| 297 |
-
# Left at Dagster's default status (stopped), so it appears in the UI and can be
|
| 298 |
-
# enabled deliberately rather than starting to poll the moment anyone runs the
|
| 299 |
-
# project. Passing default_status=None is not the way to express that: the
|
| 300 |
-
# parameter expects a DefaultScheduleStatus enum and rejects None outright.
|
| 301 |
hourly_schedule = ScheduleDefinition(
|
| 302 |
job=incremental_refresh,
|
| 303 |
cron_schedule="15 * * * *",
|
|
|
|
| 1 |
+
"""The GridPulse pipeline as Dagster assets. Run with ``dagster dev -f`` this file.
|
| 2 |
|
| 3 |
+
Do not add ``from __future__ import annotations`` here. It turns annotations into
|
| 4 |
+
strings, and Dagster reads the annotation on ``context`` to decide what to inject,
|
| 5 |
+
so the assets fail to load with a misleading error.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
import sys
|
| 10 |
from pathlib import Path
|
|
|
|
| 32 |
GROUP_SERVE = "05_serving"
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
| 35 |
@asset(
|
| 36 |
group_name=GROUP_EXTRACT,
|
| 37 |
compute_kind="python",
|
|
|
|
| 72 |
)
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
|
|
|
| 75 |
@asset(
|
| 76 |
group_name=GROUP_WAREHOUSE,
|
| 77 |
compute_kind="duckdb",
|
|
|
|
| 119 |
)
|
| 120 |
|
| 121 |
|
|
|
|
|
|
|
|
|
|
| 122 |
@asset(
|
| 123 |
group_name=GROUP_QUALITY,
|
| 124 |
compute_kind="python",
|
|
|
|
| 170 |
return AssetCheckResult(passed=rows > 1000, metadata={"benchmark_rows": rows})
|
| 171 |
|
| 172 |
|
|
|
|
|
|
|
|
|
|
| 173 |
@asset(
|
| 174 |
group_name=GROUP_ML,
|
| 175 |
compute_kind="python",
|
|
|
|
| 221 |
)
|
| 222 |
|
| 223 |
|
|
|
|
|
|
|
|
|
|
| 224 |
@asset(
|
| 225 |
group_name=GROUP_SERVE,
|
| 226 |
compute_kind="python",
|
|
|
|
| 242 |
)
|
| 243 |
|
| 244 |
|
|
|
|
|
|
|
|
|
|
| 245 |
daily_refresh = define_asset_job(
|
| 246 |
name="daily_refresh",
|
| 247 |
selection=AssetSelection.all(),
|
|
|
|
| 254 |
description="Data-only refresh without retraining. Cheap enough to run hourly.",
|
| 255 |
)
|
| 256 |
|
|
|
|
| 257 |
daily_schedule = ScheduleDefinition(
|
| 258 |
job=daily_refresh,
|
| 259 |
cron_schedule="0 6 * * *",
|
|
|
|
| 261 |
description="Nightly full refresh after EIA publishes the previous day.",
|
| 262 |
)
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
hourly_schedule = ScheduleDefinition(
|
| 265 |
job=incremental_refresh,
|
| 266 |
cron_schedule="15 * * * *",
|
scripts/update_readme.py
CHANGED
|
@@ -33,8 +33,6 @@ PRETTY = {
|
|
| 33 |
}
|
| 34 |
|
| 35 |
|
| 36 |
-
# P10/P50/P90 are the edges of a prediction interval, not competing point
|
| 37 |
-
# forecasts. Ranking them by MAPE compares things that answer different questions.
|
| 38 |
QUANTILE_MODELS = {"gbm_p10", "gbm_p50", "gbm_p90"}
|
| 39 |
|
| 40 |
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
| 36 |
QUANTILE_MODELS = {"gbm_p10", "gbm_p50", "gbm_p90"}
|
| 37 |
|
| 38 |
|
src/gridpulse/agent/text2sql.py
CHANGED
|
@@ -1,29 +1,8 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
back from it.
|
| 7 |
-
|
| 8 |
-
There are six checks, in this order:
|
| 9 |
-
|
| 10 |
-
1. **The connection is read-only.** I open the database with ``read_only=True``,
|
| 11 |
-
so even if someone completely tricked the model, it still could not change
|
| 12 |
-
anything.
|
| 13 |
-
2. **Only one statement, and it has to be a SELECT.** It must start with
|
| 14 |
-
``SELECT`` or ``WITH``. Anything else gets rejected before it runs.
|
| 15 |
-
3. **Banned keywords.** Anything that creates, changes or deletes data is refused,
|
| 16 |
-
including when it is hidden inside a comment or tacked on after a semicolon.
|
| 17 |
-
4. **A list of allowed tables.** Only my actual warehouse tables can be queried,
|
| 18 |
-
which stops anyone reading DuckDB's internal system tables.
|
| 19 |
-
5. **A forced LIMIT.** If the model forgets one, I add it, so no single query can
|
| 20 |
-
return a huge amount of data.
|
| 21 |
-
6. **The real schema goes into the prompt.** I read the actual table and column
|
| 22 |
-
names out of the database and put them in the prompt, so the model describes
|
| 23 |
-
real columns instead of inventing ones that sound plausible.
|
| 24 |
-
|
| 25 |
-
The SQL it generated always comes back along with the answer. If you cannot see
|
| 26 |
-
the query, you have no way to judge whether the answer is right.
|
| 27 |
"""
|
| 28 |
|
| 29 |
from __future__ import annotations
|
|
@@ -106,9 +85,6 @@ class AgentAnswer:
|
|
| 106 |
return self.error is None
|
| 107 |
|
| 108 |
|
| 109 |
-
# ---------------------------------------------------------------------------
|
| 110 |
-
# Guard
|
| 111 |
-
# ---------------------------------------------------------------------------
|
| 112 |
def _strip_fences(text: str) -> str:
|
| 113 |
text = re.sub(r"^\s*```(?:sql)?\s*", "", text.strip(), flags=re.IGNORECASE)
|
| 114 |
return re.sub(r"\s*```\s*$", "", text).strip()
|
|
@@ -145,7 +121,6 @@ def guard_sql(raw: str, allowed_tables: set[str] | None = None) -> str:
|
|
| 145 |
match.lower()
|
| 146 |
for match in re.findall(r"\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)", body, flags=re.IGNORECASE)
|
| 147 |
}
|
| 148 |
-
# CTE names are defined inline and are legitimate targets.
|
| 149 |
cte_names = {m.lower() for m in re.findall(r"(?:with|,)\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+as\s*\(", body, flags=re.IGNORECASE)}
|
| 150 |
unknown = referenced - allowed - cte_names
|
| 151 |
if unknown:
|
|
@@ -160,9 +135,6 @@ def guard_sql(raw: str, allowed_tables: set[str] | None = None) -> str:
|
|
| 160 |
return body
|
| 161 |
|
| 162 |
|
| 163 |
-
# ---------------------------------------------------------------------------
|
| 164 |
-
# Schema grounding
|
| 165 |
-
# ---------------------------------------------------------------------------
|
| 166 |
def introspect_schema(database=None, tables: set[str] | None = None) -> str:
|
| 167 |
"""Render a compact schema description for the prompt."""
|
| 168 |
wanted = tables or ALLOWED_TABLES
|
|
@@ -184,9 +156,6 @@ def introspect_schema(database=None, tables: set[str] | None = None) -> str:
|
|
| 184 |
return "\n".join(lines) if lines else "(warehouse is empty)"
|
| 185 |
|
| 186 |
|
| 187 |
-
# ---------------------------------------------------------------------------
|
| 188 |
-
# Agent
|
| 189 |
-
# ---------------------------------------------------------------------------
|
| 190 |
class GridAgent:
|
| 191 |
"""Question in, validated SQL and a DataFrame out."""
|
| 192 |
|
|
@@ -270,7 +239,6 @@ class GridAgent:
|
|
| 270 |
try:
|
| 271 |
sql = guard_sql(raw)
|
| 272 |
except SQLGuardError as first_failure:
|
| 273 |
-
# One repair attempt: hand the model its own error and ask again.
|
| 274 |
warnings.append(f"First attempt rejected: {first_failure}")
|
| 275 |
logger.info("Guard rejected SQL, retrying: %s", first_failure)
|
| 276 |
repair = self._get_client().chat.completions.create(
|
|
|
|
| 1 |
+
"""Turns plain English questions into DuckDB SQL, behind six safety checks.
|
| 2 |
+
|
| 3 |
+
Read-only connection, one statement only, SELECT or WITH only, a banned keyword
|
| 4 |
+
list, a table allowlist, and a forced LIMIT. The generated SQL is always returned
|
| 5 |
+
alongside the answer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
|
|
|
| 85 |
return self.error is None
|
| 86 |
|
| 87 |
|
|
|
|
|
|
|
|
|
|
| 88 |
def _strip_fences(text: str) -> str:
|
| 89 |
text = re.sub(r"^\s*```(?:sql)?\s*", "", text.strip(), flags=re.IGNORECASE)
|
| 90 |
return re.sub(r"\s*```\s*$", "", text).strip()
|
|
|
|
| 121 |
match.lower()
|
| 122 |
for match in re.findall(r"\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)", body, flags=re.IGNORECASE)
|
| 123 |
}
|
|
|
|
| 124 |
cte_names = {m.lower() for m in re.findall(r"(?:with|,)\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+as\s*\(", body, flags=re.IGNORECASE)}
|
| 125 |
unknown = referenced - allowed - cte_names
|
| 126 |
if unknown:
|
|
|
|
| 135 |
return body
|
| 136 |
|
| 137 |
|
|
|
|
|
|
|
|
|
|
| 138 |
def introspect_schema(database=None, tables: set[str] | None = None) -> str:
|
| 139 |
"""Render a compact schema description for the prompt."""
|
| 140 |
wanted = tables or ALLOWED_TABLES
|
|
|
|
| 156 |
return "\n".join(lines) if lines else "(warehouse is empty)"
|
| 157 |
|
| 158 |
|
|
|
|
|
|
|
|
|
|
| 159 |
class GridAgent:
|
| 160 |
"""Question in, validated SQL and a DataFrame out."""
|
| 161 |
|
|
|
|
| 239 |
try:
|
| 240 |
sql = guard_sql(raw)
|
| 241 |
except SQLGuardError as first_failure:
|
|
|
|
| 242 |
warnings.append(f"First attempt rejected: {first_failure}")
|
| 243 |
logger.info("Guard rejected SQL, retrying: %s", first_failure)
|
| 244 |
repair = self._get_client().chat.completions.create(
|
src/gridpulse/api/main.py
CHANGED
|
@@ -1,13 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
A thin, documented HTTP surface over the warehouse, the trained models and the
|
| 4 |
-
analytics agent. FastAPI generates OpenAPI docs at ``/docs`` automatically, so the
|
| 5 |
-
service is self-describing.
|
| 6 |
-
|
| 7 |
-
Run locally::
|
| 8 |
-
|
| 9 |
-
uvicorn gridpulse.api.main:app --reload --port 8000
|
| 10 |
-
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
| 13 |
|
|
@@ -55,9 +46,6 @@ def _query(sql: str, params: list | None = None):
|
|
| 55 |
raise HTTPException(status_code=503, detail=f"Warehouse unavailable: {exc}") from exc
|
| 56 |
|
| 57 |
|
| 58 |
-
# ---------------------------------------------------------------------------
|
| 59 |
-
# Schemas
|
| 60 |
-
# ---------------------------------------------------------------------------
|
| 61 |
class HealthResponse(BaseModel):
|
| 62 |
status: str
|
| 63 |
warehouse_present: bool
|
|
@@ -77,9 +65,6 @@ class AskRequest(BaseModel):
|
|
| 77 |
summarise: bool = True
|
| 78 |
|
| 79 |
|
| 80 |
-
# ---------------------------------------------------------------------------
|
| 81 |
-
# Routes
|
| 82 |
-
# ---------------------------------------------------------------------------
|
| 83 |
@app.get("/", include_in_schema=False)
|
| 84 |
def root() -> dict[str, Any]:
|
| 85 |
return {"service": "GridPulse API", "version": "1.0.0", "docs": "/docs"}
|
|
|
|
| 1 |
+
"""REST API over the warehouse and models. Docs at /docs when the server runs."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 46 |
raise HTTPException(status_code=503, detail=f"Warehouse unavailable: {exc}") from exc
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
| 49 |
class HealthResponse(BaseModel):
|
| 50 |
status: str
|
| 51 |
warehouse_present: bool
|
|
|
|
| 65 |
summarise: bool = True
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
| 68 |
@app.get("/", include_in_schema=False)
|
| 69 |
def root() -> dict[str, Any]:
|
| 70 |
return {"service": "GridPulse API", "version": "1.0.0", "docs": "/docs"}
|
src/gridpulse/cli.py
CHANGED
|
@@ -1,16 +1,5 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Every stage of the platform is reachable from one entry point::
|
| 4 |
-
|
| 5 |
-
gridpulse probe # validate API credentials and response contracts
|
| 6 |
-
gridpulse ingest # extract EIA + weather into bronze
|
| 7 |
-
gridpulse build # bronze -> silver -> gold warehouse
|
| 8 |
-
gridpulse quality # run the data quality suite
|
| 9 |
-
gridpulse train # train and evaluate the full model suite
|
| 10 |
-
gridpulse anomalies # fit and score anomaly detectors
|
| 11 |
-
gridpulse export # write deployment artifacts for the public app
|
| 12 |
-
gridpulse all # the entire pipeline, in order
|
| 13 |
-
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
| 16 |
|
|
@@ -44,9 +33,6 @@ def _timed(label: str, fn: Callable, *args, **kwargs):
|
|
| 44 |
return result
|
| 45 |
|
| 46 |
|
| 47 |
-
# ---------------------------------------------------------------------------
|
| 48 |
-
# Commands
|
| 49 |
-
# ---------------------------------------------------------------------------
|
| 50 |
def cmd_probe(args: argparse.Namespace) -> int:
|
| 51 |
from gridpulse.config import SETTINGS, active_bas
|
| 52 |
from gridpulse.ingestion import probe_eia
|
|
@@ -121,15 +107,12 @@ def cmd_export(args: argparse.Namespace) -> int:
|
|
| 121 |
def cmd_all(args: argparse.Namespace) -> int:
|
| 122 |
for step in (cmd_ingest, cmd_build, cmd_quality, cmd_train, cmd_anomalies, cmd_export):
|
| 123 |
code = step(args)
|
| 124 |
-
if code != 0 and step is not cmd_quality:
|
| 125 |
return code
|
| 126 |
_banner("PIPELINE COMPLETE")
|
| 127 |
return 0
|
| 128 |
|
| 129 |
|
| 130 |
-
# ---------------------------------------------------------------------------
|
| 131 |
-
# Parser
|
| 132 |
-
# ---------------------------------------------------------------------------
|
| 133 |
def build_parser() -> argparse.ArgumentParser:
|
| 134 |
parser = argparse.ArgumentParser(
|
| 135 |
prog="gridpulse",
|
|
@@ -183,7 +166,6 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 183 |
for flag in ("full_refresh", "rebuild", "quick", "eia_only", "weather_only"):
|
| 184 |
setattr(args, flag, getattr(args, flag, False))
|
| 185 |
|
| 186 |
-
# Normalise the comma-separated BA filter into a list once, here.
|
| 187 |
args.bas = [c.strip().upper() for c in args.bas.split(",") if c.strip()] if args.bas else None
|
| 188 |
try:
|
| 189 |
return args.func(args)
|
|
|
|
| 1 |
+
"""One command for every stage: probe, ingest, build, quality, train, anomalies,
|
| 2 |
+
export, or all of them in order."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
|
|
| 33 |
return result
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
| 36 |
def cmd_probe(args: argparse.Namespace) -> int:
|
| 37 |
from gridpulse.config import SETTINGS, active_bas
|
| 38 |
from gridpulse.ingestion import probe_eia
|
|
|
|
| 107 |
def cmd_all(args: argparse.Namespace) -> int:
|
| 108 |
for step in (cmd_ingest, cmd_build, cmd_quality, cmd_train, cmd_anomalies, cmd_export):
|
| 109 |
code = step(args)
|
| 110 |
+
if code != 0 and step is not cmd_quality:
|
| 111 |
return code
|
| 112 |
_banner("PIPELINE COMPLETE")
|
| 113 |
return 0
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
| 116 |
def build_parser() -> argparse.ArgumentParser:
|
| 117 |
parser = argparse.ArgumentParser(
|
| 118 |
prog="gridpulse",
|
|
|
|
| 166 |
for flag in ("full_refresh", "rebuild", "quick", "eia_only", "weather_only"):
|
| 167 |
setattr(args, flag, getattr(args, flag, False))
|
| 168 |
|
|
|
|
| 169 |
args.bas = [c.strip().upper() for c in args.bas.split(",") if c.strip()] if args.bas else None
|
| 170 |
try:
|
| 171 |
return args.func(args)
|
src/gridpulse/config.py
CHANGED
|
@@ -1,8 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Every path in the project resolves from :data:`PATHS`, so the same code runs
|
| 4 |
-
identically on a laptop, in CI and on Streamlit Community Cloud.
|
| 5 |
-
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
@@ -10,7 +6,7 @@ import os
|
|
| 10 |
from dataclasses import dataclass, field
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
-
try:
|
| 14 |
from dotenv import load_dotenv
|
| 15 |
|
| 16 |
load_dotenv()
|
|
@@ -31,17 +27,10 @@ def _resolve(p: str) -> Path:
|
|
| 31 |
return path if path.is_absolute() else REPO_ROOT / path
|
| 32 |
|
| 33 |
|
| 34 |
-
# ---------------------------------------------------------------------------
|
| 35 |
-
# Balancing authority registry
|
| 36 |
-
# ---------------------------------------------------------------------------
|
| 37 |
@dataclass(frozen=True)
|
| 38 |
class BalancingAuthority:
|
| 39 |
-
"""A
|
| 40 |
-
|
| 41 |
-
``latitude``/``longitude`` point at the dominant population centre inside the
|
| 42 |
-
BA footprint. Electricity demand is overwhelmingly driven by weather where the
|
| 43 |
-
people are, not by the geographic centroid of the territory.
|
| 44 |
-
"""
|
| 45 |
|
| 46 |
code: str
|
| 47 |
name: str
|
|
@@ -87,14 +76,11 @@ def active_bas() -> list[BalancingAuthority]:
|
|
| 87 |
return [BALANCING_AUTHORITIES[c] for c in requested]
|
| 88 |
|
| 89 |
|
| 90 |
-
# ---------------------------------------------------------------------------
|
| 91 |
-
# Measure registry (EIA-930 `type` facet on the region-data endpoint)
|
| 92 |
-
# ---------------------------------------------------------------------------
|
| 93 |
EIA_MEASURES: dict[str, str] = {
|
| 94 |
-
"D": "demand_mwh",
|
| 95 |
-
"DF": "demand_forecast_mwh",
|
| 96 |
-
"NG": "net_generation_mwh",
|
| 97 |
-
"TI": "total_interchange_mwh",
|
| 98 |
}
|
| 99 |
|
| 100 |
WEATHER_VARIABLES: list[str] = [
|
|
@@ -108,9 +94,6 @@ WEATHER_VARIABLES: list[str] = [
|
|
| 108 |
]
|
| 109 |
|
| 110 |
|
| 111 |
-
# ---------------------------------------------------------------------------
|
| 112 |
-
# Paths
|
| 113 |
-
# ---------------------------------------------------------------------------
|
| 114 |
@dataclass(frozen=True)
|
| 115 |
class Paths:
|
| 116 |
data: Path = field(default_factory=lambda: _resolve(_env("GRIDPULSE_DATA_DIR", "data")))
|
|
@@ -143,9 +126,6 @@ class Paths:
|
|
| 143 |
PATHS = Paths()
|
| 144 |
|
| 145 |
|
| 146 |
-
# ---------------------------------------------------------------------------
|
| 147 |
-
# Runtime settings
|
| 148 |
-
# ---------------------------------------------------------------------------
|
| 149 |
@dataclass(frozen=True)
|
| 150 |
class Settings:
|
| 151 |
eia_api_key: str = field(default_factory=lambda: _env("EIA_API_KEY", ""))
|
|
@@ -171,7 +151,6 @@ class Settings:
|
|
| 171 |
|
| 172 |
SETTINGS = Settings()
|
| 173 |
|
| 174 |
-
# Horizon of the forecasting problem: predict the next 24 hours from the prior 7 days.
|
| 175 |
FORECAST_HORIZON = 24
|
| 176 |
LOOKBACK_HOURS = 168
|
| 177 |
QUANTILES = (0.1, 0.5, 0.9)
|
|
|
|
| 1 |
+
"""Settings, file paths and the list of 12 balancing authorities."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 6 |
from dataclasses import dataclass, field
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
+
try:
|
| 10 |
from dotenv import load_dotenv
|
| 11 |
|
| 12 |
load_dotenv()
|
|
|
|
| 27 |
return path if path.is_absolute() else REPO_ROOT / path
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
| 30 |
@dataclass(frozen=True)
|
| 31 |
class BalancingAuthority:
|
| 32 |
+
"""A region, with coordinates pointing at its biggest city rather than its
|
| 33 |
+
geographic centre, since demand follows the weather where people live."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
code: str
|
| 36 |
name: str
|
|
|
|
| 76 |
return [BALANCING_AUTHORITIES[c] for c in requested]
|
| 77 |
|
| 78 |
|
|
|
|
|
|
|
|
|
|
| 79 |
EIA_MEASURES: dict[str, str] = {
|
| 80 |
+
"D": "demand_mwh",
|
| 81 |
+
"DF": "demand_forecast_mwh",
|
| 82 |
+
"NG": "net_generation_mwh",
|
| 83 |
+
"TI": "total_interchange_mwh",
|
| 84 |
}
|
| 85 |
|
| 86 |
WEATHER_VARIABLES: list[str] = [
|
|
|
|
| 94 |
]
|
| 95 |
|
| 96 |
|
|
|
|
|
|
|
|
|
|
| 97 |
@dataclass(frozen=True)
|
| 98 |
class Paths:
|
| 99 |
data: Path = field(default_factory=lambda: _resolve(_env("GRIDPULSE_DATA_DIR", "data")))
|
|
|
|
| 126 |
PATHS = Paths()
|
| 127 |
|
| 128 |
|
|
|
|
|
|
|
|
|
|
| 129 |
@dataclass(frozen=True)
|
| 130 |
class Settings:
|
| 131 |
eia_api_key: str = field(default_factory=lambda: _env("EIA_API_KEY", ""))
|
|
|
|
| 151 |
|
| 152 |
SETTINGS = Settings()
|
| 153 |
|
|
|
|
| 154 |
FORECAST_HORIZON = 24
|
| 155 |
LOOKBACK_HOURS = 168
|
| 156 |
QUANTILES = (0.1, 0.5, 0.9)
|
src/gridpulse/features/build.py
CHANGED
|
@@ -1,33 +1,7 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
**Calendar features**
|
| 7 |
-
Hour, weekday and season are encoded as sine and cosine pairs, so that hour 23
|
| 8 |
-
sits right next to hour 0 instead of looking 23 units away from it. Holidays,
|
| 9 |
-
and the days either side of them, get their own flags, because office and
|
| 10 |
-
factory demand drops off a cliff on those days.
|
| 11 |
-
|
| 12 |
-
**Lags**
|
| 13 |
-
Demand from 24, 48 and 168 hours ago. The 168 hour one (exactly a week) is the
|
| 14 |
-
strongest single predictor there is for this problem. Last Tuesday at 3pm looks
|
| 15 |
-
far more like this Tuesday at 3pm than 3am this morning does.
|
| 16 |
-
|
| 17 |
-
**Rolling averages**
|
| 18 |
-
Averages and standard deviations over recent hours, all shifted back by the
|
| 19 |
-
full 24 hour forecast horizon, so nothing that would only be known after
|
| 20 |
-
prediction time can sneak in.
|
| 21 |
-
|
| 22 |
-
**Weather**
|
| 23 |
-
Temperature, plus heating degrees and cooling degrees kept as separate columns.
|
| 24 |
-
Demand against temperature is V-shaped rather than a straight line, so splitting
|
| 25 |
-
it into the cold side and the hot side lets even a simple model pick it up, and
|
| 26 |
-
helps a tree model find the turning point faster.
|
| 27 |
-
|
| 28 |
-
On leakage: every feature only uses information that existed before the moment
|
| 29 |
-
being predicted. The one exception is the weather, and that is fine, because a real
|
| 30 |
-
grid operator also has tomorrow's weather forecast in hand.
|
| 31 |
"""
|
| 32 |
|
| 33 |
from __future__ import annotations
|
|
@@ -42,18 +16,8 @@ from gridpulse.warehouse.duck import query
|
|
| 42 |
|
| 43 |
logger = logging.getLogger(__name__)
|
| 44 |
|
| 45 |
-
# Comfort baseline in Celsius. Below it people heat, above it they cool.
|
| 46 |
BALANCE_POINT_C = 18.0
|
| 47 |
|
| 48 |
-
# Physically plausible band for demand, expressed as a multiple of each balancing
|
| 49 |
-
# authority's own median. Real system load is remarkably well behaved: even the
|
| 50 |
-
# most extreme heatwave peak sits under twice the annual median, and the deepest
|
| 51 |
-
# overnight trough stays above a third of it. Anything outside this band is a
|
| 52 |
-
# telemetry fault, not weather.
|
| 53 |
-
#
|
| 54 |
-
# This matters more than it looks. The raw EIA feed contains occasional readings
|
| 55 |
-
# several orders of magnitude too large, and a handful of them inflated PJM's
|
| 56 |
-
# standard deviation to 10.7 million MW against a true range near 70,000-165,000 MW.
|
| 57 |
DEMAND_PLAUSIBLE_LOWER = 0.2
|
| 58 |
DEMAND_PLAUSIBLE_UPPER = 5.0
|
| 59 |
|
|
@@ -61,22 +25,17 @@ LAG_HOURS = (24, 25, 26, 48, 72, 168, 336)
|
|
| 61 |
ROLLING_WINDOWS = (24, 168)
|
| 62 |
|
| 63 |
FEATURE_COLUMNS: list[str] = [
|
| 64 |
-
# cyclical calendar
|
| 65 |
"hour_sin", "hour_cos", "dow_sin", "dow_cos", "doy_sin", "doy_cos",
|
| 66 |
-
# categorical calendar
|
| 67 |
"is_weekend", "is_holiday", "is_business_day",
|
| 68 |
"is_day_before_holiday", "is_day_after_holiday",
|
| 69 |
-
# autoregressive
|
| 70 |
*[f"demand_lag_{h}h" for h in LAG_HOURS],
|
| 71 |
*[f"demand_roll_mean_{w}h" for w in ROLLING_WINDOWS],
|
| 72 |
*[f"demand_roll_std_{w}h" for w in ROLLING_WINDOWS],
|
| 73 |
"demand_same_hour_last_week_delta",
|
| 74 |
-
# weather
|
| 75 |
"temperature_2m", "apparent_temperature", "relative_humidity_2m",
|
| 76 |
"dew_point_2m", "cloud_cover", "wind_speed_10m", "shortwave_radiation",
|
| 77 |
"heating_degrees", "cooling_degrees", "temp_squared",
|
| 78 |
"temp_lag_24h", "temp_change_24h", "temp_roll_mean_24h",
|
| 79 |
-
# interactions
|
| 80 |
"cooling_x_business", "heating_x_business", "cooling_x_hour",
|
| 81 |
]
|
| 82 |
|
|
@@ -109,11 +68,7 @@ def load_modelling_frame(ba_codes: list[str] | None = None) -> pd.DataFrame:
|
|
| 109 |
|
| 110 |
|
| 111 |
def flag_implausible_demand(frame: pd.DataFrame, target: str = TARGET) -> pd.Series:
|
| 112 |
-
"""True where demand is impossible
|
| 113 |
-
|
| 114 |
-
Bounds are derived from the median rather than the mean precisely because the
|
| 115 |
-
outliers being hunted would drag a mean toward themselves and hide.
|
| 116 |
-
"""
|
| 117 |
median = frame.groupby("ba_code")[target].transform("median")
|
| 118 |
return (frame[target] < median * DEMAND_PLAUSIBLE_LOWER) | (
|
| 119 |
frame[target] > median * DEMAND_PLAUSIBLE_UPPER
|
|
@@ -130,7 +85,6 @@ def _engineer_one_ba(frame: pd.DataFrame) -> pd.DataFrame:
|
|
| 130 |
"""Build features for a single BA. Assumes the frame is sorted by period."""
|
| 131 |
out = frame.sort_values("period_utc").copy()
|
| 132 |
|
| 133 |
-
# ---- calendar --------------------------------------------------------
|
| 134 |
_cyclical(out, out["hour_local"], 24, "hour")
|
| 135 |
_cyclical(out, out["day_of_week"], 7, "dow")
|
| 136 |
_cyclical(out, pd.to_datetime(out["date_local"]).dt.dayofyear, 365.25, "doy")
|
|
@@ -139,24 +93,19 @@ def _engineer_one_ba(frame: pd.DataFrame) -> pd.DataFrame:
|
|
| 139 |
"is_day_before_holiday", "is_day_after_holiday"):
|
| 140 |
out[flag] = out[flag].fillna(False).astype(int)
|
| 141 |
|
| 142 |
-
# ---- autoregressive --------------------------------------------------
|
| 143 |
demand = out[TARGET]
|
| 144 |
for lag in LAG_HOURS:
|
| 145 |
out[f"demand_lag_{lag}h"] = demand.shift(lag)
|
| 146 |
|
| 147 |
-
# Rolling windows are shifted by the full horizon: at prediction time for
|
| 148 |
-
# hour t we only possess observations up to t - FORECAST_HORIZON.
|
| 149 |
shifted = demand.shift(FORECAST_HORIZON)
|
| 150 |
for window in ROLLING_WINDOWS:
|
| 151 |
out[f"demand_roll_mean_{window}h"] = shifted.rolling(window, min_periods=window // 4).mean()
|
| 152 |
out[f"demand_roll_std_{window}h"] = shifted.rolling(window, min_periods=window // 4).std()
|
| 153 |
|
| 154 |
-
# Week-on-week momentum at the same hour of day.
|
| 155 |
out["demand_same_hour_last_week_delta"] = (
|
| 156 |
out["demand_lag_168h"] - out["demand_lag_336h"]
|
| 157 |
)
|
| 158 |
|
| 159 |
-
# ---- weather ---------------------------------------------------------
|
| 160 |
temp = out["temperature_2m"]
|
| 161 |
out["heating_degrees"] = (BALANCE_POINT_C - temp).clip(lower=0)
|
| 162 |
out["cooling_degrees"] = (temp - BALANCE_POINT_C).clip(lower=0)
|
|
@@ -165,11 +114,8 @@ def _engineer_one_ba(frame: pd.DataFrame) -> pd.DataFrame:
|
|
| 165 |
out["temp_change_24h"] = temp - out["temp_lag_24h"]
|
| 166 |
out["temp_roll_mean_24h"] = temp.rolling(24, min_periods=6).mean()
|
| 167 |
|
| 168 |
-
# ---- interactions ----------------------------------------------------
|
| 169 |
-
# Offices and shops only run their air conditioning on working days.
|
| 170 |
out["cooling_x_business"] = out["cooling_degrees"] * out["is_business_day"]
|
| 171 |
out["heating_x_business"] = out["heating_degrees"] * out["is_business_day"]
|
| 172 |
-
# Afternoon heat compounds: the same 35C bites harder at 4pm than at 4am.
|
| 173 |
out["cooling_x_hour"] = out["cooling_degrees"] * out["hour_local"]
|
| 174 |
|
| 175 |
return out
|
|
@@ -180,17 +126,10 @@ def build_features(
|
|
| 180 |
ba_codes: list[str] | None = None,
|
| 181 |
dropna_target: bool = True,
|
| 182 |
) -> pd.DataFrame:
|
| 183 |
-
"""
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
frame
|
| 188 |
-
Pre-loaded gold data. Loaded from the warehouse when omitted.
|
| 189 |
-
ba_codes
|
| 190 |
-
Restrict to these balancing authorities.
|
| 191 |
-
dropna_target
|
| 192 |
-
Drop rows with no actual demand. Set False when building an inference
|
| 193 |
-
frame for future timestamps, where the target is legitimately unknown.
|
| 194 |
"""
|
| 195 |
source = load_modelling_frame(ba_codes) if frame is None else frame
|
| 196 |
|
|
@@ -199,7 +138,6 @@ def build_features(
|
|
| 199 |
ignore_index=True,
|
| 200 |
)
|
| 201 |
|
| 202 |
-
# Weather can be sparse at the very edges of the archive/forecast seam.
|
| 203 |
weather_columns = [
|
| 204 |
"temperature_2m", "apparent_temperature", "relative_humidity_2m",
|
| 205 |
"dew_point_2m", "cloud_cover", "wind_speed_10m", "shortwave_radiation",
|
|
@@ -209,9 +147,6 @@ def build_features(
|
|
| 209 |
.transform(lambda s: s.interpolate(limit=6, limit_direction="both"))
|
| 210 |
)
|
| 211 |
|
| 212 |
-
# Remove physically impossible readings before any statistic is computed from
|
| 213 |
-
# them. They are flagged in the warehouse and surfaced by the quality suite;
|
| 214 |
-
# here they are simply excluded from modelling.
|
| 215 |
if dropna_target:
|
| 216 |
implausible = flag_implausible_demand(engineered)
|
| 217 |
if implausible.any():
|
|
@@ -223,7 +158,6 @@ def build_features(
|
|
| 223 |
engineered = engineered[~implausible]
|
| 224 |
engineered = engineered.dropna(subset=[TARGET])
|
| 225 |
|
| 226 |
-
# The deepest lag needs 336 hours of warm-up; those rows can never be complete.
|
| 227 |
required = [c for c in FEATURE_COLUMNS if c.startswith("demand_lag")]
|
| 228 |
engineered = engineered.dropna(subset=required)
|
| 229 |
|
|
@@ -237,11 +171,7 @@ def build_features(
|
|
| 237 |
def chronological_split(
|
| 238 |
frame: pd.DataFrame, test_days: int = 90, valid_days: int = 60
|
| 239 |
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
| 240 |
-
"""Split
|
| 241 |
-
|
| 242 |
-
A random split on time-series data lets the model peek at the future through
|
| 243 |
-
neighbouring rows and produces gorgeous, meaningless validation scores.
|
| 244 |
-
"""
|
| 245 |
cutoff_test = frame["period_utc"].max() - pd.Timedelta(days=test_days)
|
| 246 |
cutoff_valid = cutoff_test - pd.Timedelta(days=valid_days)
|
| 247 |
|
|
|
|
| 1 |
+
"""Builds the 40 model features: calendar, lags, rolling stats and weather.
|
| 2 |
+
|
| 3 |
+
Everything derived from past demand is shifted back by the full forecast horizon,
|
| 4 |
+
so no feature can see anything that was not available at prediction time.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
|
|
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
|
|
|
| 19 |
BALANCE_POINT_C = 18.0
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
DEMAND_PLAUSIBLE_LOWER = 0.2
|
| 22 |
DEMAND_PLAUSIBLE_UPPER = 5.0
|
| 23 |
|
|
|
|
| 25 |
ROLLING_WINDOWS = (24, 168)
|
| 26 |
|
| 27 |
FEATURE_COLUMNS: list[str] = [
|
|
|
|
| 28 |
"hour_sin", "hour_cos", "dow_sin", "dow_cos", "doy_sin", "doy_cos",
|
|
|
|
| 29 |
"is_weekend", "is_holiday", "is_business_day",
|
| 30 |
"is_day_before_holiday", "is_day_after_holiday",
|
|
|
|
| 31 |
*[f"demand_lag_{h}h" for h in LAG_HOURS],
|
| 32 |
*[f"demand_roll_mean_{w}h" for w in ROLLING_WINDOWS],
|
| 33 |
*[f"demand_roll_std_{w}h" for w in ROLLING_WINDOWS],
|
| 34 |
"demand_same_hour_last_week_delta",
|
|
|
|
| 35 |
"temperature_2m", "apparent_temperature", "relative_humidity_2m",
|
| 36 |
"dew_point_2m", "cloud_cover", "wind_speed_10m", "shortwave_radiation",
|
| 37 |
"heating_degrees", "cooling_degrees", "temp_squared",
|
| 38 |
"temp_lag_24h", "temp_change_24h", "temp_roll_mean_24h",
|
|
|
|
| 39 |
"cooling_x_business", "heating_x_business", "cooling_x_hour",
|
| 40 |
]
|
| 41 |
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
def flag_implausible_demand(frame: pd.DataFrame, target: str = TARGET) -> pd.Series:
|
| 71 |
+
"""True where demand is impossible compared to that region's own median."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
median = frame.groupby("ba_code")[target].transform("median")
|
| 73 |
return (frame[target] < median * DEMAND_PLAUSIBLE_LOWER) | (
|
| 74 |
frame[target] > median * DEMAND_PLAUSIBLE_UPPER
|
|
|
|
| 85 |
"""Build features for a single BA. Assumes the frame is sorted by period."""
|
| 86 |
out = frame.sort_values("period_utc").copy()
|
| 87 |
|
|
|
|
| 88 |
_cyclical(out, out["hour_local"], 24, "hour")
|
| 89 |
_cyclical(out, out["day_of_week"], 7, "dow")
|
| 90 |
_cyclical(out, pd.to_datetime(out["date_local"]).dt.dayofyear, 365.25, "doy")
|
|
|
|
| 93 |
"is_day_before_holiday", "is_day_after_holiday"):
|
| 94 |
out[flag] = out[flag].fillna(False).astype(int)
|
| 95 |
|
|
|
|
| 96 |
demand = out[TARGET]
|
| 97 |
for lag in LAG_HOURS:
|
| 98 |
out[f"demand_lag_{lag}h"] = demand.shift(lag)
|
| 99 |
|
|
|
|
|
|
|
| 100 |
shifted = demand.shift(FORECAST_HORIZON)
|
| 101 |
for window in ROLLING_WINDOWS:
|
| 102 |
out[f"demand_roll_mean_{window}h"] = shifted.rolling(window, min_periods=window // 4).mean()
|
| 103 |
out[f"demand_roll_std_{window}h"] = shifted.rolling(window, min_periods=window // 4).std()
|
| 104 |
|
|
|
|
| 105 |
out["demand_same_hour_last_week_delta"] = (
|
| 106 |
out["demand_lag_168h"] - out["demand_lag_336h"]
|
| 107 |
)
|
| 108 |
|
|
|
|
| 109 |
temp = out["temperature_2m"]
|
| 110 |
out["heating_degrees"] = (BALANCE_POINT_C - temp).clip(lower=0)
|
| 111 |
out["cooling_degrees"] = (temp - BALANCE_POINT_C).clip(lower=0)
|
|
|
|
| 114 |
out["temp_change_24h"] = temp - out["temp_lag_24h"]
|
| 115 |
out["temp_roll_mean_24h"] = temp.rolling(24, min_periods=6).mean()
|
| 116 |
|
|
|
|
|
|
|
| 117 |
out["cooling_x_business"] = out["cooling_degrees"] * out["is_business_day"]
|
| 118 |
out["heating_x_business"] = out["heating_degrees"] * out["is_business_day"]
|
|
|
|
| 119 |
out["cooling_x_hour"] = out["cooling_degrees"] * out["hour_local"]
|
| 120 |
|
| 121 |
return out
|
|
|
|
| 126 |
ba_codes: list[str] | None = None,
|
| 127 |
dropna_target: bool = True,
|
| 128 |
) -> pd.DataFrame:
|
| 129 |
+
"""Build the full feature table the models train on.
|
| 130 |
+
|
| 131 |
+
Set ``dropna_target=False`` when building features for future hours, where
|
| 132 |
+
there is no actual demand to compare against yet.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
"""
|
| 134 |
source = load_modelling_frame(ba_codes) if frame is None else frame
|
| 135 |
|
|
|
|
| 138 |
ignore_index=True,
|
| 139 |
)
|
| 140 |
|
|
|
|
| 141 |
weather_columns = [
|
| 142 |
"temperature_2m", "apparent_temperature", "relative_humidity_2m",
|
| 143 |
"dew_point_2m", "cloud_cover", "wind_speed_10m", "shortwave_radiation",
|
|
|
|
| 147 |
.transform(lambda s: s.interpolate(limit=6, limit_direction="both"))
|
| 148 |
)
|
| 149 |
|
|
|
|
|
|
|
|
|
|
| 150 |
if dropna_target:
|
| 151 |
implausible = flag_implausible_demand(engineered)
|
| 152 |
if implausible.any():
|
|
|
|
| 158 |
engineered = engineered[~implausible]
|
| 159 |
engineered = engineered.dropna(subset=[TARGET])
|
| 160 |
|
|
|
|
| 161 |
required = [c for c in FEATURE_COLUMNS if c.startswith("demand_lag")]
|
| 162 |
engineered = engineered.dropna(subset=required)
|
| 163 |
|
|
|
|
| 171 |
def chronological_split(
|
| 172 |
frame: pd.DataFrame, test_days: int = 90, valid_days: int = 60
|
| 173 |
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
| 174 |
+
"""Split by date only, never randomly, so the model cannot see the future."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
cutoff_test = frame["period_utc"].max() - pd.Timedelta(days=test_days)
|
| 176 |
cutoff_valid = cutoff_test - pd.Timedelta(days=valid_days)
|
| 177 |
|
src/gridpulse/ingestion/eia.py
CHANGED
|
@@ -1,22 +1,7 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
========== ====================================================================
|
| 6 |
-
``D`` Demand in MWh, which is what I am trying to predict
|
| 7 |
-
``DF`` EIA's own day-ahead forecast, which is what I compare against
|
| 8 |
-
``NG`` Net generation in MWh
|
| 9 |
-
``TI`` Power traded with neighbouring regions, in MWh
|
| 10 |
-
========== ====================================================================
|
| 11 |
-
|
| 12 |
-
The ``DF`` series is the important one. Instead of making up my own easy baseline,
|
| 13 |
-
I score every model against the forecast the US government actually published and
|
| 14 |
-
actually ran the grid against that day.
|
| 15 |
-
|
| 16 |
-
Downloads only fetch what is new. Each run looks at the latest timestamp already
|
| 17 |
-
saved and asks for periods after that, so a scheduled run costs a handful of
|
| 18 |
-
requests rather than downloading everything again. Running it twice does not create
|
| 19 |
-
duplicates.
|
| 20 |
"""
|
| 21 |
|
| 22 |
from __future__ import annotations
|
|
@@ -37,16 +22,12 @@ logger = logging.getLogger(__name__)
|
|
| 37 |
EIA_BASE = "https://api.eia.gov/v2"
|
| 38 |
REGION_DATA_ROUTE = f"{EIA_BASE}/electricity/rto/region-data/data/"
|
| 39 |
|
| 40 |
-
# EIA-930 collection began 1 July 2015; requests before this return nothing.
|
| 41 |
EIA_EPOCH = "2015-07-01"
|
| 42 |
|
| 43 |
BRONZE_SUBDIR = "eia_region"
|
| 44 |
_SCHEMA = ["period_utc", "ba_code", "measure_code", "value_mwh", "ingested_at_utc"]
|
| 45 |
|
| 46 |
|
| 47 |
-
# ---------------------------------------------------------------------------
|
| 48 |
-
# Bronze layout helpers
|
| 49 |
-
# ---------------------------------------------------------------------------
|
| 50 |
def bronze_path(ba_code: str) -> Path:
|
| 51 |
return PATHS.bronze / BRONZE_SUBDIR / f"ba={ba_code}" / "data.parquet"
|
| 52 |
|
|
@@ -68,9 +49,6 @@ def watermark(ba_code: str) -> str | None:
|
|
| 68 |
return latest.strftime("%Y-%m-%dT%H")
|
| 69 |
|
| 70 |
|
| 71 |
-
# ---------------------------------------------------------------------------
|
| 72 |
-
# Request construction
|
| 73 |
-
# ---------------------------------------------------------------------------
|
| 74 |
def _build_params(
|
| 75 |
ba_code: str, start: str, end: str, offset: int, length: int
|
| 76 |
) -> list[tuple[str, str]]:
|
|
@@ -103,7 +81,6 @@ def _normalise(records: list[dict]) -> pd.DataFrame:
|
|
| 103 |
df = pd.DataFrame(records)
|
| 104 |
out = pd.DataFrame(
|
| 105 |
{
|
| 106 |
-
# EIA hourly periods are UTC, formatted YYYY-MM-DDTHH
|
| 107 |
"period_utc": pd.to_datetime(df["period"], format="%Y-%m-%dT%H", utc=True, errors="coerce"),
|
| 108 |
"ba_code": df["respondent"].astype("string"),
|
| 109 |
"measure_code": df["type"].astype("string"),
|
|
@@ -115,11 +92,7 @@ def _normalise(records: list[dict]) -> pd.DataFrame:
|
|
| 115 |
|
| 116 |
|
| 117 |
def _merge(existing: pd.DataFrame, fresh: pd.DataFrame) -> pd.DataFrame:
|
| 118 |
-
"""Merge new rows in. If a row already exists, the newest download wins.
|
| 119 |
-
|
| 120 |
-
Written this way so that running the download twice does not create duplicate
|
| 121 |
-
rows, which matters because the scheduled job can overlap with a manual run.
|
| 122 |
-
"""
|
| 123 |
if existing.empty:
|
| 124 |
combined = fresh
|
| 125 |
elif fresh.empty:
|
|
@@ -140,9 +113,6 @@ def _merge(existing: pd.DataFrame, fresh: pd.DataFrame) -> pd.DataFrame:
|
|
| 140 |
return combined
|
| 141 |
|
| 142 |
|
| 143 |
-
# ---------------------------------------------------------------------------
|
| 144 |
-
# Async fetch
|
| 145 |
-
# ---------------------------------------------------------------------------
|
| 146 |
async def _fetch_ba(
|
| 147 |
client: httpx.AsyncClient,
|
| 148 |
ba: BalancingAuthority,
|
|
@@ -200,7 +170,6 @@ async def _ingest_async(bas: list[BalancingAuthority], full_refresh: bool) -> di
|
|
| 200 |
for ba in bas:
|
| 201 |
mark = None if full_refresh else watermark(ba.code)
|
| 202 |
if mark:
|
| 203 |
-
# Re-request the final stored day: EIA revises recent hours in place.
|
| 204 |
start_ts = pd.Timestamp(mark, tz="UTC") - pd.Timedelta(hours=24)
|
| 205 |
start = start_ts.strftime("%Y-%m-%dT%H")
|
| 206 |
else:
|
|
@@ -218,9 +187,6 @@ async def _ingest_async(bas: list[BalancingAuthority], full_refresh: bool) -> di
|
|
| 218 |
return written
|
| 219 |
|
| 220 |
|
| 221 |
-
# ---------------------------------------------------------------------------
|
| 222 |
-
# Public entry points
|
| 223 |
-
# ---------------------------------------------------------------------------
|
| 224 |
def ingest_eia(ba_codes: list[str] | None = None, full_refresh: bool = False) -> dict[str, int]:
|
| 225 |
"""Extract EIA-930 hourly telemetry into the bronze zone.
|
| 226 |
|
|
|
|
| 1 |
+
"""Downloads hourly demand, EIA's own forecast, generation and interchange.
|
| 2 |
|
| 3 |
+
Only fetches periods newer than what is already saved, and running it twice does
|
| 4 |
+
not create duplicate rows.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
|
|
|
| 22 |
EIA_BASE = "https://api.eia.gov/v2"
|
| 23 |
REGION_DATA_ROUTE = f"{EIA_BASE}/electricity/rto/region-data/data/"
|
| 24 |
|
|
|
|
| 25 |
EIA_EPOCH = "2015-07-01"
|
| 26 |
|
| 27 |
BRONZE_SUBDIR = "eia_region"
|
| 28 |
_SCHEMA = ["period_utc", "ba_code", "measure_code", "value_mwh", "ingested_at_utc"]
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
| 31 |
def bronze_path(ba_code: str) -> Path:
|
| 32 |
return PATHS.bronze / BRONZE_SUBDIR / f"ba={ba_code}" / "data.parquet"
|
| 33 |
|
|
|
|
| 49 |
return latest.strftime("%Y-%m-%dT%H")
|
| 50 |
|
| 51 |
|
|
|
|
|
|
|
|
|
|
| 52 |
def _build_params(
|
| 53 |
ba_code: str, start: str, end: str, offset: int, length: int
|
| 54 |
) -> list[tuple[str, str]]:
|
|
|
|
| 81 |
df = pd.DataFrame(records)
|
| 82 |
out = pd.DataFrame(
|
| 83 |
{
|
|
|
|
| 84 |
"period_utc": pd.to_datetime(df["period"], format="%Y-%m-%dT%H", utc=True, errors="coerce"),
|
| 85 |
"ba_code": df["respondent"].astype("string"),
|
| 86 |
"measure_code": df["type"].astype("string"),
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
def _merge(existing: pd.DataFrame, fresh: pd.DataFrame) -> pd.DataFrame:
|
| 95 |
+
"""Merge new rows in. If a row already exists, the newest download wins."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
if existing.empty:
|
| 97 |
combined = fresh
|
| 98 |
elif fresh.empty:
|
|
|
|
| 113 |
return combined
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
| 116 |
async def _fetch_ba(
|
| 117 |
client: httpx.AsyncClient,
|
| 118 |
ba: BalancingAuthority,
|
|
|
|
| 170 |
for ba in bas:
|
| 171 |
mark = None if full_refresh else watermark(ba.code)
|
| 172 |
if mark:
|
|
|
|
| 173 |
start_ts = pd.Timestamp(mark, tz="UTC") - pd.Timedelta(hours=24)
|
| 174 |
start = start_ts.strftime("%Y-%m-%dT%H")
|
| 175 |
else:
|
|
|
|
| 187 |
return written
|
| 188 |
|
| 189 |
|
|
|
|
|
|
|
|
|
|
| 190 |
def ingest_eia(ba_codes: list[str] | None = None, full_refresh: bool = False) -> dict[str, int]:
|
| 191 |
"""Extract EIA-930 hourly telemetry into the bronze zone.
|
| 192 |
|
src/gridpulse/ingestion/http.py
CHANGED
|
@@ -1,9 +1,4 @@
|
|
| 1 |
-
"""Shared
|
| 2 |
-
|
| 3 |
-
Public data APIs rate limit aggressively and fail transiently. Every network call
|
| 4 |
-
in GridPulse funnels through :func:`fetch_json` so retry policy lives in exactly
|
| 5 |
-
one place rather than being copy-pasted per source.
|
| 6 |
-
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
@@ -22,9 +17,6 @@ RATE_LIMIT_STATUS = 429
|
|
| 22 |
MAX_ATTEMPTS = 6
|
| 23 |
BASE_BACKOFF = 1.5
|
| 24 |
|
| 25 |
-
# A rate limit is not a transient blip. Open-Meteo and similar public APIs meter
|
| 26 |
-
# by weighted request cost over a rolling window, so the only useful response is
|
| 27 |
-
# to wait meaningfully rather than retry three seconds later.
|
| 28 |
RATE_LIMIT_BASE_WAIT = 20.0
|
| 29 |
RATE_LIMIT_MAX_WAIT = 90.0
|
| 30 |
|
|
|
|
| 1 |
+
"""Shared HTTP helper so the retry and backoff rules live in one place."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 17 |
MAX_ATTEMPTS = 6
|
| 18 |
BASE_BACKOFF = 1.5
|
| 19 |
|
|
|
|
|
|
|
|
|
|
| 20 |
RATE_LIMIT_BASE_WAIT = 20.0
|
| 21 |
RATE_LIMIT_MAX_WAIT = 90.0
|
| 22 |
|
src/gridpulse/ingestion/weather.py
CHANGED
|
@@ -1,19 +1,7 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
demand-vs-temperature curve that every load forecaster models.
|
| 6 |
-
|
| 7 |
-
Two endpoints are stitched together because neither alone is sufficient:
|
| 8 |
-
|
| 9 |
-
* **ERA5 archive** -- authoritative reanalysis, but lags real time by about 5 days.
|
| 10 |
-
* **Forecast endpoint** -- serves ``past_days`` of recent observations plus up to
|
| 11 |
-
16 days ahead, closing the archive gap and supplying the *future* covariates the
|
| 12 |
-
day-ahead model needs at inference time.
|
| 13 |
-
|
| 14 |
-
Overlapping hours resolve in favour of the archive, which is the more accurate
|
| 15 |
-
source. Getting this seam right is the difference between a model that works in a
|
| 16 |
-
notebook and one that works tomorrow morning.
|
| 17 |
"""
|
| 18 |
|
| 19 |
from __future__ import annotations
|
|
@@ -35,13 +23,10 @@ ARCHIVE_ROUTE = "https://archive-api.open-meteo.com/v1/archive"
|
|
| 35 |
FORECAST_ROUTE = "https://api.open-meteo.com/v1/forecast"
|
| 36 |
|
| 37 |
BRONZE_SUBDIR = "weather"
|
| 38 |
-
ARCHIVE_LAG_DAYS = 6
|
| 39 |
-
FORECAST_PAST_DAYS = 92
|
| 40 |
-
FORECAST_AHEAD_DAYS = 16
|
| 41 |
|
| 42 |
-
# Open-Meteo's free tier meters weighted request cost over a rolling window.
|
| 43 |
-
# A multi-year hourly archive pull is a heavy call, so requests are serialised
|
| 44 |
-
# and spaced. This costs about a minute across all BAs and removes 429s entirely.
|
| 45 |
INTER_REQUEST_PAUSE = 2.0
|
| 46 |
|
| 47 |
|
|
@@ -123,7 +108,6 @@ def _merge(*frames: pd.DataFrame) -> pd.DataFrame:
|
|
| 123 |
|
| 124 |
combined = pd.concat(populated, ignore_index=True)
|
| 125 |
combined["period_utc"] = pd.to_datetime(combined["period_utc"], utc=True)
|
| 126 |
-
# Rank so the archive sorts last and therefore wins `keep="last"`.
|
| 127 |
combined["_priority"] = (combined["source"] == "era5_archive").astype(int)
|
| 128 |
combined = (
|
| 129 |
combined.sort_values(["period_utc", "_priority"])
|
|
@@ -136,7 +120,6 @@ def _merge(*frames: pd.DataFrame) -> pd.DataFrame:
|
|
| 136 |
|
| 137 |
|
| 138 |
async def _ingest_async(bas: list[BalancingAuthority], full_refresh: bool) -> dict[str, int]:
|
| 139 |
-
# Serialised deliberately: see INTER_REQUEST_PAUSE above.
|
| 140 |
sem = asyncio.Semaphore(1)
|
| 141 |
today = datetime.now(timezone.utc).date()
|
| 142 |
archive_end = today - timedelta(days=ARCHIVE_LAG_DAYS)
|
|
@@ -156,7 +139,6 @@ async def _ingest_async(bas: list[BalancingAuthority], full_refresh: bool) -> di
|
|
| 156 |
else SETTINGS.start_date
|
| 157 |
)
|
| 158 |
|
| 159 |
-
# Sequential rather than gathered, with a pause between calls.
|
| 160 |
archive = await _fetch_archive(client, ba, archive_start, archive_end.isoformat(), sem)
|
| 161 |
await asyncio.sleep(INTER_REQUEST_PAUSE)
|
| 162 |
forecast = await _fetch_forecast(client, ba, sem)
|
|
|
|
| 1 |
+
"""Downloads weather from Open-Meteo, no API key needed.
|
| 2 |
|
| 3 |
+
Joins the ERA5 archive, which lags about 5 days, to the forecast endpoint, which
|
| 4 |
+
covers the gap and supplies tomorrow's weather. Overlapping hours use the archive.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
|
|
|
| 23 |
FORECAST_ROUTE = "https://api.open-meteo.com/v1/forecast"
|
| 24 |
|
| 25 |
BRONZE_SUBDIR = "weather"
|
| 26 |
+
ARCHIVE_LAG_DAYS = 6
|
| 27 |
+
FORECAST_PAST_DAYS = 92
|
| 28 |
+
FORECAST_AHEAD_DAYS = 16
|
| 29 |
|
|
|
|
|
|
|
|
|
|
| 30 |
INTER_REQUEST_PAUSE = 2.0
|
| 31 |
|
| 32 |
|
|
|
|
| 108 |
|
| 109 |
combined = pd.concat(populated, ignore_index=True)
|
| 110 |
combined["period_utc"] = pd.to_datetime(combined["period_utc"], utc=True)
|
|
|
|
| 111 |
combined["_priority"] = (combined["source"] == "era5_archive").astype(int)
|
| 112 |
combined = (
|
| 113 |
combined.sort_values(["period_utc", "_priority"])
|
|
|
|
| 120 |
|
| 121 |
|
| 122 |
async def _ingest_async(bas: list[BalancingAuthority], full_refresh: bool) -> dict[str, int]:
|
|
|
|
| 123 |
sem = asyncio.Semaphore(1)
|
| 124 |
today = datetime.now(timezone.utc).date()
|
| 125 |
archive_end = today - timedelta(days=ARCHIVE_LAG_DAYS)
|
|
|
|
| 139 |
else SETTINGS.start_date
|
| 140 |
)
|
| 141 |
|
|
|
|
| 142 |
archive = await _fetch_archive(client, ba, archive_start, archive_end.isoformat(), sem)
|
| 143 |
await asyncio.sleep(INTER_REQUEST_PAUSE)
|
| 144 |
forecast = await _fetch_forecast(client, ba, sem)
|
src/gridpulse/models/anomaly.py
CHANGED
|
@@ -1,31 +1,5 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
I use three detectors and make them vote, because each one on its own misses a
|
| 4 |
-
different kind of problem:
|
| 5 |
-
|
| 6 |
-
**Seasonal z-score**
|
| 7 |
-
Compares each hour against the median demand for that region, at that hour of
|
| 8 |
-
the day, in that month, then scales by the median absolute deviation. I use
|
| 9 |
-
MAD rather than standard deviation because the weird values I am looking for
|
| 10 |
-
would inflate a standard deviation and end up hiding themselves. This catches
|
| 11 |
-
single spikes and drops, but it misses slow drift over several hours.
|
| 12 |
-
|
| 13 |
-
**Isolation Forest**
|
| 14 |
-
Looks at several things at once: demand level, how fast demand is changing,
|
| 15 |
-
temperature, and how demand relates to temperature. This catches combinations
|
| 16 |
-
that look fine one at a time. Normal demand at a normal temperature can still
|
| 17 |
-
be odd if those two never usually go together.
|
| 18 |
-
|
| 19 |
-
**Autoencoder on the daily shape**
|
| 20 |
-
A small neural network squeezes each day's 24 hour shape down to 8 numbers and
|
| 21 |
-
then tries to rebuild it. Days whose shape is unlike anything it trained on come
|
| 22 |
-
back badly rebuilt, even if every individual hour looks normal on its own. This
|
| 23 |
-
is the one that spots holidays acting like weekends, storm days, and days where
|
| 24 |
-
customers were paid to use less power.
|
| 25 |
-
|
| 26 |
-
Making them vote keeps the false alarms down: an hour is only marked ``high``
|
| 27 |
-
severity when at least two of the three detectors agree it is strange.
|
| 28 |
-
"""
|
| 29 |
|
| 30 |
from __future__ import annotations
|
| 31 |
|
|
@@ -40,14 +14,11 @@ from gridpulse.warehouse.duck import connect, query
|
|
| 40 |
|
| 41 |
logger = logging.getLogger(__name__)
|
| 42 |
|
| 43 |
-
Z_THRESHOLD = 4.0
|
| 44 |
-
CONTAMINATION = 0.01
|
| 45 |
-
AE_PERCENTILE = 99.0
|
| 46 |
|
| 47 |
|
| 48 |
-
# ---------------------------------------------------------------------------
|
| 49 |
-
# Detector 1: robust seasonal z-score
|
| 50 |
-
# ---------------------------------------------------------------------------
|
| 51 |
def robust_seasonal_z(frame: pd.DataFrame, target: str = "demand_mwh") -> pd.Series:
|
| 52 |
"""Median-absolute-deviation z-score within (BA, hour-of-day, month) cells."""
|
| 53 |
work = frame[["ba_code", "hour_local", "month", target]].copy()
|
|
@@ -55,14 +26,10 @@ def robust_seasonal_z(frame: pd.DataFrame, target: str = "demand_mwh") -> pd.Ser
|
|
| 55 |
|
| 56 |
median = grouped.transform("median")
|
| 57 |
mad = grouped.transform(lambda s: (s - s.median()).abs().median())
|
| 58 |
-
# 0.6745 rescales MAD to be comparable with a standard deviation under normality.
|
| 59 |
scale = (mad / 0.6745).replace(0, np.nan)
|
| 60 |
return ((work[target] - median) / scale).abs().fillna(0.0)
|
| 61 |
|
| 62 |
|
| 63 |
-
# ---------------------------------------------------------------------------
|
| 64 |
-
# Detector 2: Isolation Forest
|
| 65 |
-
# ---------------------------------------------------------------------------
|
| 66 |
ISO_FEATURES = ["demand_mwh", "ramp_mwh", "ramp_pct", "temperature_2m", "demand_per_degree"]
|
| 67 |
|
| 68 |
|
|
@@ -70,8 +37,6 @@ def _isolation_features(frame: pd.DataFrame) -> pd.DataFrame:
|
|
| 70 |
work = frame.copy()
|
| 71 |
work["ramp_mwh"] = work.groupby("ba_code")["demand_mwh"].diff()
|
| 72 |
work["ramp_pct"] = work["ramp_mwh"] / work.groupby("ba_code")["demand_mwh"].shift(1) * 100
|
| 73 |
-
# Demand normalised by distance from the comfort balance point: how much load
|
| 74 |
-
# each degree of heating or cooling demand is buying.
|
| 75 |
departure = (work["temperature_2m"] - 18.0).abs().clip(lower=0.5)
|
| 76 |
work["demand_per_degree"] = work["demand_mwh"] / departure
|
| 77 |
return work[ISO_FEATURES].replace([np.inf, -np.inf], np.nan)
|
|
@@ -92,24 +57,13 @@ def isolation_forest_scores(frame: pd.DataFrame, contamination: float = CONTAMIN
|
|
| 92 |
),
|
| 93 |
)
|
| 94 |
model.fit(features)
|
| 95 |
-
# decision_function is high for normal points; negate so high means anomalous.
|
| 96 |
raw = -model[-1].decision_function(model[:-1].transform(features))
|
| 97 |
return pd.Series(raw, index=frame.index), model
|
| 98 |
|
| 99 |
|
| 100 |
-
# ---------------------------------------------------------------------------
|
| 101 |
-
# Detector 3: daily-profile autoencoder
|
| 102 |
-
# ---------------------------------------------------------------------------
|
| 103 |
def _daily_profiles(frame: pd.DataFrame) -> tuple[np.ndarray, pd.DataFrame]:
|
| 104 |
-
"""
|
| 105 |
-
|
| 106 |
-
Each day is divided by its own **median** so the autoencoder learns load
|
| 107 |
-
*shape* rather than which BA is largest. The median is used instead of the
|
| 108 |
-
mean because a single corrupt hour drags a mean toward itself; if that mean
|
| 109 |
-
lands near zero the division explodes and the reconstruction loss becomes
|
| 110 |
-
meaningless. Days whose level is not comfortably positive are dropped rather
|
| 111 |
-
than rescued, since their shape cannot be trusted anyway.
|
| 112 |
-
"""
|
| 113 |
pivot = (
|
| 114 |
frame.pivot_table(index=["ba_code", "date_local"], columns="hour_local",
|
| 115 |
values="demand_mwh", aggfunc="mean")
|
|
@@ -130,8 +84,6 @@ def _daily_profiles(frame: pd.DataFrame) -> tuple[np.ndarray, pd.DataFrame]:
|
|
| 130 |
return np.empty((0, 24), dtype=np.float32), pd.DataFrame()
|
| 131 |
|
| 132 |
normalised = values / level
|
| 133 |
-
# A normalised day should sit near 1.0 throughout. Anything beyond this band
|
| 134 |
-
# is a corrupt reading, not a load shape, and would dominate the loss.
|
| 135 |
keep = (normalised > 0.05).all(axis=1) & (normalised < 20.0).all(axis=1)
|
| 136 |
dropped = int((~keep).sum())
|
| 137 |
if dropped:
|
|
@@ -160,7 +112,7 @@ def autoencoder_scores(frame: pd.DataFrame, quick: bool = False) -> pd.DataFrame
|
|
| 160 |
|
| 161 |
model = nn.Sequential(
|
| 162 |
nn.Linear(n_hours, 32), nn.ReLU(),
|
| 163 |
-
nn.Linear(32, 8), nn.ReLU(),
|
| 164 |
nn.Linear(8, 32), nn.ReLU(),
|
| 165 |
nn.Linear(32, n_hours),
|
| 166 |
)
|
|
@@ -194,9 +146,6 @@ def autoencoder_scores(frame: pd.DataFrame, quick: bool = False) -> pd.DataFrame
|
|
| 194 |
return out
|
| 195 |
|
| 196 |
|
| 197 |
-
# ---------------------------------------------------------------------------
|
| 198 |
-
# Orchestration
|
| 199 |
-
# ---------------------------------------------------------------------------
|
| 200 |
def classify_anomaly(row: pd.Series) -> str:
|
| 201 |
"""Human-readable label so an operator knows what they are looking at."""
|
| 202 |
if row.get("flag_frozen_reading"):
|
|
@@ -230,29 +179,23 @@ def run_anomaly_detection(quick: bool = False, persist: bool = True) -> pd.DataF
|
|
| 230 |
|
| 231 |
frame["period_utc"] = pd.to_datetime(frame["period_utc"], utc=True)
|
| 232 |
|
| 233 |
-
# Physically impossible readings are already flagged by the warehouse and
|
| 234 |
-
# reported by the quality suite. Feeding them to statistical detectors would
|
| 235 |
-
# let them define the very distribution used to judge everything else.
|
| 236 |
implausible = frame["flag_implausible_magnitude"].fillna(False).astype(bool)
|
| 237 |
if implausible.any():
|
| 238 |
logger.info(" excluding %d implausible reading(s) from scoring", int(implausible.sum()))
|
| 239 |
scored = frame[frame["demand_mwh"].notna() & ~implausible].copy()
|
| 240 |
logger.info("Scoring %s hours for anomalies", f"{len(scored):,}")
|
| 241 |
|
| 242 |
-
# Detector 1
|
| 243 |
logger.info(" detector 1/3: robust seasonal z-score")
|
| 244 |
scored["robust_z"] = robust_seasonal_z(scored)
|
| 245 |
grouped = scored.groupby(["ba_code", "hour_local", "month"])["demand_mwh"]
|
| 246 |
scored["residual_sign"] = np.sign(scored["demand_mwh"] - grouped.transform("median"))
|
| 247 |
|
| 248 |
-
# Detector 2
|
| 249 |
logger.info(" detector 2/3: isolation forest")
|
| 250 |
scored["iso_score"], _ = isolation_forest_scores(scored)
|
| 251 |
scored["iso_flag"] = scored["iso_score"] > scored["iso_score"].quantile(1 - CONTAMINATION)
|
| 252 |
scored["ramp_mwh"] = scored.groupby("ba_code")["demand_mwh"].diff()
|
| 253 |
scored["ramp_pct"] = scored["ramp_mwh"] / scored.groupby("ba_code")["demand_mwh"].shift(1) * 100
|
| 254 |
|
| 255 |
-
# Detector 3
|
| 256 |
logger.info(" detector 3/3: daily-profile autoencoder")
|
| 257 |
daily = autoencoder_scores(scored, quick=quick)
|
| 258 |
if not daily.empty:
|
|
@@ -266,7 +209,6 @@ def run_anomaly_detection(quick: bool = False, persist: bool = True) -> pd.DataF
|
|
| 266 |
|
| 267 |
scored["ae_anomalous_day"] = scored["ae_anomalous_day"].astype("boolean").fillna(False).astype(bool)
|
| 268 |
|
| 269 |
-
# Consensus
|
| 270 |
scored["z_flag"] = scored["robust_z"] > Z_THRESHOLD
|
| 271 |
def _as_bool(column: str) -> pd.Series:
|
| 272 |
return scored[column].astype("boolean").fillna(False).astype(bool)
|
|
|
|
| 1 |
+
"""Finds unusual hours using three detectors that have to agree: seasonal
|
| 2 |
+
z-score, Isolation Forest and an autoencoder trained on daily demand shapes."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
|
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
+
Z_THRESHOLD = 4.0
|
| 18 |
+
CONTAMINATION = 0.01
|
| 19 |
+
AE_PERCENTILE = 99.0
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
| 22 |
def robust_seasonal_z(frame: pd.DataFrame, target: str = "demand_mwh") -> pd.Series:
|
| 23 |
"""Median-absolute-deviation z-score within (BA, hour-of-day, month) cells."""
|
| 24 |
work = frame[["ba_code", "hour_local", "month", target]].copy()
|
|
|
|
| 26 |
|
| 27 |
median = grouped.transform("median")
|
| 28 |
mad = grouped.transform(lambda s: (s - s.median()).abs().median())
|
|
|
|
| 29 |
scale = (mad / 0.6745).replace(0, np.nan)
|
| 30 |
return ((work[target] - median) / scale).abs().fillna(0.0)
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
| 33 |
ISO_FEATURES = ["demand_mwh", "ramp_mwh", "ramp_pct", "temperature_2m", "demand_per_degree"]
|
| 34 |
|
| 35 |
|
|
|
|
| 37 |
work = frame.copy()
|
| 38 |
work["ramp_mwh"] = work.groupby("ba_code")["demand_mwh"].diff()
|
| 39 |
work["ramp_pct"] = work["ramp_mwh"] / work.groupby("ba_code")["demand_mwh"].shift(1) * 100
|
|
|
|
|
|
|
| 40 |
departure = (work["temperature_2m"] - 18.0).abs().clip(lower=0.5)
|
| 41 |
work["demand_per_degree"] = work["demand_mwh"] / departure
|
| 42 |
return work[ISO_FEATURES].replace([np.inf, -np.inf], np.nan)
|
|
|
|
| 57 |
),
|
| 58 |
)
|
| 59 |
model.fit(features)
|
|
|
|
| 60 |
raw = -model[-1].decision_function(model[:-1].transform(features))
|
| 61 |
return pd.Series(raw, index=frame.index), model
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
|
|
|
| 64 |
def _daily_profiles(frame: pd.DataFrame) -> tuple[np.ndarray, pd.DataFrame]:
|
| 65 |
+
"""Turn each day into 24 numbers scaled by that day's median, so the model
|
| 66 |
+
learns the shape of a day rather than which region is biggest."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
pivot = (
|
| 68 |
frame.pivot_table(index=["ba_code", "date_local"], columns="hour_local",
|
| 69 |
values="demand_mwh", aggfunc="mean")
|
|
|
|
| 84 |
return np.empty((0, 24), dtype=np.float32), pd.DataFrame()
|
| 85 |
|
| 86 |
normalised = values / level
|
|
|
|
|
|
|
| 87 |
keep = (normalised > 0.05).all(axis=1) & (normalised < 20.0).all(axis=1)
|
| 88 |
dropped = int((~keep).sum())
|
| 89 |
if dropped:
|
|
|
|
| 112 |
|
| 113 |
model = nn.Sequential(
|
| 114 |
nn.Linear(n_hours, 32), nn.ReLU(),
|
| 115 |
+
nn.Linear(32, 8), nn.ReLU(),
|
| 116 |
nn.Linear(8, 32), nn.ReLU(),
|
| 117 |
nn.Linear(32, n_hours),
|
| 118 |
)
|
|
|
|
| 146 |
return out
|
| 147 |
|
| 148 |
|
|
|
|
|
|
|
|
|
|
| 149 |
def classify_anomaly(row: pd.Series) -> str:
|
| 150 |
"""Human-readable label so an operator knows what they are looking at."""
|
| 151 |
if row.get("flag_frozen_reading"):
|
|
|
|
| 179 |
|
| 180 |
frame["period_utc"] = pd.to_datetime(frame["period_utc"], utc=True)
|
| 181 |
|
|
|
|
|
|
|
|
|
|
| 182 |
implausible = frame["flag_implausible_magnitude"].fillna(False).astype(bool)
|
| 183 |
if implausible.any():
|
| 184 |
logger.info(" excluding %d implausible reading(s) from scoring", int(implausible.sum()))
|
| 185 |
scored = frame[frame["demand_mwh"].notna() & ~implausible].copy()
|
| 186 |
logger.info("Scoring %s hours for anomalies", f"{len(scored):,}")
|
| 187 |
|
|
|
|
| 188 |
logger.info(" detector 1/3: robust seasonal z-score")
|
| 189 |
scored["robust_z"] = robust_seasonal_z(scored)
|
| 190 |
grouped = scored.groupby(["ba_code", "hour_local", "month"])["demand_mwh"]
|
| 191 |
scored["residual_sign"] = np.sign(scored["demand_mwh"] - grouped.transform("median"))
|
| 192 |
|
|
|
|
| 193 |
logger.info(" detector 2/3: isolation forest")
|
| 194 |
scored["iso_score"], _ = isolation_forest_scores(scored)
|
| 195 |
scored["iso_flag"] = scored["iso_score"] > scored["iso_score"].quantile(1 - CONTAMINATION)
|
| 196 |
scored["ramp_mwh"] = scored.groupby("ba_code")["demand_mwh"].diff()
|
| 197 |
scored["ramp_pct"] = scored["ramp_mwh"] / scored.groupby("ba_code")["demand_mwh"].shift(1) * 100
|
| 198 |
|
|
|
|
| 199 |
logger.info(" detector 3/3: daily-profile autoencoder")
|
| 200 |
daily = autoencoder_scores(scored, quick=quick)
|
| 201 |
if not daily.empty:
|
|
|
|
| 209 |
|
| 210 |
scored["ae_anomalous_day"] = scored["ae_anomalous_day"].astype("boolean").fillna(False).astype(bool)
|
| 211 |
|
|
|
|
| 212 |
scored["z_flag"] = scored["robust_z"] > Z_THRESHOLD
|
| 213 |
def _as_bool(column: str) -> pd.Series:
|
| 214 |
return scored[column].astype("boolean").fillna(False).astype(bool)
|
src/gridpulse/models/baselines.py
CHANGED
|
@@ -1,22 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Every forecasting claim is meaningless without a floor to compare against. These
|
| 4 |
-
three establish it:
|
| 5 |
-
|
| 6 |
-
**Seasonal naive (24h)**
|
| 7 |
-
Tomorrow at 3pm equals today at 3pm. Trivial, and startlingly hard to beat.
|
| 8 |
-
|
| 9 |
-
**Weekly naive (168h)**
|
| 10 |
-
Tomorrow at 3pm equals the same weekday last week at 3pm. Usually stronger than
|
| 11 |
-
the daily variant because it preserves the weekday/weekend regime.
|
| 12 |
-
|
| 13 |
-
**Holt-Winters**
|
| 14 |
-
Triple exponential smoothing with a daily seasonal cycle: a real statistical
|
| 15 |
-
model, no exogenous inputs, representing what a utility analyst could build in
|
| 16 |
-
a spreadsheet.
|
| 17 |
-
|
| 18 |
-
If a deep network cannot beat weekly naive, the deep network is not working.
|
| 19 |
-
"""
|
| 20 |
|
| 21 |
from __future__ import annotations
|
| 22 |
|
|
|
|
| 1 |
+
"""Simple baselines to beat: seasonal naive, weekly naive and Holt-Winters."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
src/gridpulse/models/deep.py
CHANGED
|
@@ -1,32 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
How they are put together
|
| 4 |
-
-------------------------
|
| 5 |
-
Both models use the same idea as the Temporal Fusion Transformer, cut down to
|
| 6 |
-
something that trains in minutes on a laptop instead of hours on a GPU:
|
| 7 |
-
|
| 8 |
-
* An **encoder** reads the last 168 hours of what actually happened: demand,
|
| 9 |
-
weather, and the time of day and week.
|
| 10 |
-
* A **second branch** reads the next 24 hours of things we already know, which is
|
| 11 |
-
the weather forecast and the calendar. This is not cheating. A real grid
|
| 12 |
-
operator making a day-ahead forecast genuinely has tomorrow's weather forecast
|
| 13 |
-
and knows what day of the week it is. Hiding that would mean solving a harder
|
| 14 |
-
problem than the real one.
|
| 15 |
-
* A **final layer** combines the two and predicts all 24 hours at once, instead of
|
| 16 |
-
predicting one hour and feeding it back in. Feeding predictions back in makes
|
| 17 |
-
small errors pile up.
|
| 18 |
-
|
| 19 |
-
There are two versions of the encoder: a stacked LSTM, and a small Transformer
|
| 20 |
-
with sinusoidal position encoding. At this amount of data the LSTM usually does
|
| 21 |
-
better. I included the Transformer because it scales better when you have far more
|
| 22 |
-
series, and because I wanted to actually build the attention part rather than just
|
| 23 |
-
read about it.
|
| 24 |
-
|
| 25 |
-
One thing I had to be careful about is memory. The training windows are never all
|
| 26 |
-
built at once. The dataset keeps one flat float32 array and cuts each window out
|
| 27 |
-
of it only when it is asked for, so memory stays in the tens of megabytes no
|
| 28 |
-
matter how much history there is.
|
| 29 |
-
"""
|
| 30 |
|
| 31 |
from __future__ import annotations
|
| 32 |
|
|
@@ -43,7 +15,6 @@ from gridpulse.config import FORECAST_HORIZON, LOOKBACK_HOURS, PATHS
|
|
| 43 |
|
| 44 |
logger = logging.getLogger(__name__)
|
| 45 |
|
| 46 |
-
# Channels observed in the past and fed to the encoder.
|
| 47 |
PAST_CHANNELS = [
|
| 48 |
"demand_mwh",
|
| 49 |
"temperature_2m", "apparent_temperature", "relative_humidity_2m",
|
|
@@ -53,7 +24,6 @@ PAST_CHANNELS = [
|
|
| 53 |
"is_business_day", "is_holiday",
|
| 54 |
]
|
| 55 |
|
| 56 |
-
# Channels known in advance for the forecast window.
|
| 57 |
FUTURE_CHANNELS = [
|
| 58 |
"temperature_2m", "apparent_temperature", "relative_humidity_2m",
|
| 59 |
"cloud_cover", "wind_speed_10m",
|
|
@@ -64,33 +34,14 @@ FUTURE_CHANNELS = [
|
|
| 64 |
|
| 65 |
TARGET = "demand_mwh"
|
| 66 |
|
| 67 |
-
# Sliding windows at hourly resolution overlap by 167 of 168 input hours, so
|
| 68 |
-
# adjacent samples are almost perfectly redundant. Striding keeps the sample
|
| 69 |
-
# diverse while cutting epoch time by the stride factor. Six hours is a natural
|
| 70 |
-
# choice: it still covers every phase of the daily cycle within a single day.
|
| 71 |
TRAIN_STRIDE = 12
|
| 72 |
QUICK_TRAIN_STRIDE = 24
|
| 73 |
|
| 74 |
-
# Recurrent layers are inherently sequential: timestep t cannot be computed until
|
| 75 |
-
# t-1 finishes, so cost scales linearly with sequence length and cannot be
|
| 76 |
-
# parallelised away. A 168-step encoder is therefore expensive on a CPU.
|
| 77 |
-
#
|
| 78 |
-
# The lookback window is subsampled every ENCODER_STRIDE hours, giving 56 steps
|
| 79 |
-
# instead of 168. This is not a shortcut: hourly demand is heavily autocorrelated,
|
| 80 |
-
# so consecutive hours carry little independent information, and the retained
|
| 81 |
-
# points still span the full week and every phase of the daily cycle. The recent
|
| 82 |
-
# past is preserved exactly where it matters most through the lag and rolling
|
| 83 |
-
# features already supplied to the gradient-boosted model.
|
| 84 |
ENCODER_STRIDE = 3
|
| 85 |
|
| 86 |
-
# Each test window predicts 24 consecutive hours, so a stride of 6 still yields
|
| 87 |
-
# four independent predictions for every hour, which are averaged.
|
| 88 |
TEST_STRIDE = 6
|
| 89 |
|
| 90 |
|
| 91 |
-
# ---------------------------------------------------------------------------
|
| 92 |
-
# Dataset
|
| 93 |
-
# ---------------------------------------------------------------------------
|
| 94 |
def _torch():
|
| 95 |
try:
|
| 96 |
import torch
|
|
@@ -133,7 +84,6 @@ class WindowDataset:
|
|
| 133 |
split = start + self.lookback
|
| 134 |
end = split + self.horizon
|
| 135 |
return (
|
| 136 |
-
# Subsampled: 168 hourly steps become 56 three-hourly steps.
|
| 137 |
torch.from_numpy(self.past[start:split:ENCODER_STRIDE]),
|
| 138 |
torch.from_numpy(self.future[split:end]),
|
| 139 |
torch.from_numpy(self.target[split:end]),
|
|
@@ -155,9 +105,6 @@ class ConcatDataset:
|
|
| 155 |
return self.datasets[which][index - int(self.offsets[which])]
|
| 156 |
|
| 157 |
|
| 158 |
-
# ---------------------------------------------------------------------------
|
| 159 |
-
# Models
|
| 160 |
-
# ---------------------------------------------------------------------------
|
| 161 |
def build_lstm(n_past: int, n_future: int, hidden: int = 64, layers: int = 1, dropout: float = 0.15):
|
| 162 |
torch = _torch()
|
| 163 |
nn = torch.nn
|
|
@@ -179,9 +126,9 @@ def build_lstm(n_past: int, n_future: int, hidden: int = 64, layers: int = 1, dr
|
|
| 179 |
|
| 180 |
def forward(self, past, future):
|
| 181 |
_, (hidden_state, _) = self.encoder(past)
|
| 182 |
-
context = hidden_state[-1]
|
| 183 |
-
known = self.future_proj(future.flatten(start_dim=1))
|
| 184 |
-
return self.head(torch.cat([context, known], dim=1))
|
| 185 |
|
| 186 |
return LSTMForecaster()
|
| 187 |
|
|
@@ -225,16 +172,13 @@ def build_transformer(
|
|
| 225 |
|
| 226 |
def forward(self, past, future):
|
| 227 |
encoded = self.encoder(self.pos(self.input_proj(past)))
|
| 228 |
-
context = encoded.mean(dim=1)
|
| 229 |
known = self.future_proj(future.flatten(start_dim=1))
|
| 230 |
return self.head(torch.cat([context, known], dim=1))
|
| 231 |
|
| 232 |
return TransformerForecaster()
|
| 233 |
|
| 234 |
|
| 235 |
-
# ---------------------------------------------------------------------------
|
| 236 |
-
# Scaling
|
| 237 |
-
# ---------------------------------------------------------------------------
|
| 238 |
@dataclass
|
| 239 |
class Scaler:
|
| 240 |
"""Per-channel standardisation. Statistics come from training data only."""
|
|
@@ -262,22 +206,13 @@ class Scaler:
|
|
| 262 |
|
| 263 |
@dataclass
|
| 264 |
class TargetScaler:
|
| 265 |
-
"""
|
| 266 |
-
|
| 267 |
-
BA demand spans two orders of magnitude (ISNE peaks near 25 GW, PJM near
|
| 268 |
-
150 GW). Without per-BA normalisation the loss is dominated entirely by the
|
| 269 |
-
largest system and the small ones never learn.
|
| 270 |
-
"""
|
| 271 |
|
| 272 |
stats: dict[str, tuple[float, float]]
|
| 273 |
|
| 274 |
@classmethod
|
| 275 |
def fit(cls, frame: pd.DataFrame) -> TargetScaler:
|
| 276 |
-
"""
|
| 277 |
-
|
| 278 |
-
See ``gbm.BATargetScaler.fit`` for why mean and standard deviation are
|
| 279 |
-
unusable here: corrupt readings in the raw feed inflate them without limit.
|
| 280 |
-
"""
|
| 281 |
grouped = frame.groupby("ba_code")[TARGET]
|
| 282 |
centre = grouped.median()
|
| 283 |
spread = (grouped.quantile(0.75) - grouped.quantile(0.25)) / 1.349
|
|
@@ -305,18 +240,15 @@ class TargetScaler:
|
|
| 305 |
return cls({k: (v[0], v[1]) for k, v in payload["stats"].items()})
|
| 306 |
|
| 307 |
|
| 308 |
-
# ---------------------------------------------------------------------------
|
| 309 |
-
# Windowing across the full series
|
| 310 |
-
# ---------------------------------------------------------------------------
|
| 311 |
@dataclass
|
| 312 |
class SeriesBundle:
|
| 313 |
"""Everything needed to build windows for a single balancing authority."""
|
| 314 |
|
| 315 |
ba_code: str
|
| 316 |
-
past: np.ndarray
|
| 317 |
-
future: np.ndarray
|
| 318 |
-
target_scaled: np.ndarray
|
| 319 |
-
target_raw: np.ndarray
|
| 320 |
timestamps: pd.DatetimeIndex
|
| 321 |
|
| 322 |
|
|
@@ -346,12 +278,10 @@ def split_windows(
|
|
| 346 |
train_stride: int = TRAIN_STRIDE,
|
| 347 |
test_stride: int = TEST_STRIDE,
|
| 348 |
) -> tuple[ConcatDataset, ConcatDataset, list[tuple[SeriesBundle, np.ndarray]]]:
|
| 349 |
-
"""
|
| 350 |
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
when its entire forecast horizon lies in the test period, which makes the
|
| 354 |
-
evaluation strictly out-of-sample.
|
| 355 |
"""
|
| 356 |
train_sets, valid_sets, test_specs = [], [], []
|
| 357 |
|
|
@@ -397,9 +327,6 @@ class _SubsetWindows(WindowDataset):
|
|
| 397 |
return super().__getitem__(int(self.indices[index]))
|
| 398 |
|
| 399 |
|
| 400 |
-
# ---------------------------------------------------------------------------
|
| 401 |
-
# Training
|
| 402 |
-
# ---------------------------------------------------------------------------
|
| 403 |
@dataclass
|
| 404 |
class TrainedDeepModel:
|
| 405 |
architecture: str
|
|
@@ -513,8 +440,6 @@ def train_deep(
|
|
| 513 |
|
| 514 |
optimiser = torch.optim.AdamW(model.parameters(), lr=2e-3, weight_decay=1e-4)
|
| 515 |
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimiser, factor=0.5, patience=2)
|
| 516 |
-
# Huber is deliberately chosen over MSE: load series contain genuine spikes
|
| 517 |
-
# (heatwaves, storms) and MSE would let a handful of them dominate the gradient.
|
| 518 |
criterion = torch.nn.HuberLoss(delta=1.0)
|
| 519 |
|
| 520 |
max_epochs = 4 if quick else 15
|
|
@@ -573,11 +498,7 @@ def train_deep(
|
|
| 573 |
|
| 574 |
|
| 575 |
def _predict_test(model, test_specs, target_scaler: TargetScaler, batch_size: int = 256) -> pd.DataFrame:
|
| 576 |
-
"""
|
| 577 |
-
|
| 578 |
-
Overlapping windows produce several predictions for the same hour; they are
|
| 579 |
-
averaged, which is a cheap ensembling effect and smooths window-edge artefacts.
|
| 580 |
-
"""
|
| 581 |
torch = _torch()
|
| 582 |
model.eval()
|
| 583 |
rows = []
|
|
|
|
| 1 |
+
"""PyTorch LSTM and Transformer forecasters, sized to train on a laptop CPU."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
|
|
|
| 18 |
PAST_CHANNELS = [
|
| 19 |
"demand_mwh",
|
| 20 |
"temperature_2m", "apparent_temperature", "relative_humidity_2m",
|
|
|
|
| 24 |
"is_business_day", "is_holiday",
|
| 25 |
]
|
| 26 |
|
|
|
|
| 27 |
FUTURE_CHANNELS = [
|
| 28 |
"temperature_2m", "apparent_temperature", "relative_humidity_2m",
|
| 29 |
"cloud_cover", "wind_speed_10m",
|
|
|
|
| 34 |
|
| 35 |
TARGET = "demand_mwh"
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
TRAIN_STRIDE = 12
|
| 38 |
QUICK_TRAIN_STRIDE = 24
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
ENCODER_STRIDE = 3
|
| 41 |
|
|
|
|
|
|
|
| 42 |
TEST_STRIDE = 6
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
| 45 |
def _torch():
|
| 46 |
try:
|
| 47 |
import torch
|
|
|
|
| 84 |
split = start + self.lookback
|
| 85 |
end = split + self.horizon
|
| 86 |
return (
|
|
|
|
| 87 |
torch.from_numpy(self.past[start:split:ENCODER_STRIDE]),
|
| 88 |
torch.from_numpy(self.future[split:end]),
|
| 89 |
torch.from_numpy(self.target[split:end]),
|
|
|
|
| 105 |
return self.datasets[which][index - int(self.offsets[which])]
|
| 106 |
|
| 107 |
|
|
|
|
|
|
|
|
|
|
| 108 |
def build_lstm(n_past: int, n_future: int, hidden: int = 64, layers: int = 1, dropout: float = 0.15):
|
| 109 |
torch = _torch()
|
| 110 |
nn = torch.nn
|
|
|
|
| 126 |
|
| 127 |
def forward(self, past, future):
|
| 128 |
_, (hidden_state, _) = self.encoder(past)
|
| 129 |
+
context = hidden_state[-1]
|
| 130 |
+
known = self.future_proj(future.flatten(start_dim=1))
|
| 131 |
+
return self.head(torch.cat([context, known], dim=1))
|
| 132 |
|
| 133 |
return LSTMForecaster()
|
| 134 |
|
|
|
|
| 172 |
|
| 173 |
def forward(self, past, future):
|
| 174 |
encoded = self.encoder(self.pos(self.input_proj(past)))
|
| 175 |
+
context = encoded.mean(dim=1)
|
| 176 |
known = self.future_proj(future.flatten(start_dim=1))
|
| 177 |
return self.head(torch.cat([context, known], dim=1))
|
| 178 |
|
| 179 |
return TransformerForecaster()
|
| 180 |
|
| 181 |
|
|
|
|
|
|
|
|
|
|
| 182 |
@dataclass
|
| 183 |
class Scaler:
|
| 184 |
"""Per-channel standardisation. Statistics come from training data only."""
|
|
|
|
| 206 |
|
| 207 |
@dataclass
|
| 208 |
class TargetScaler:
|
| 209 |
+
"""Scales demand per region, so the biggest regions do not dominate training."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
stats: dict[str, tuple[float, float]]
|
| 212 |
|
| 213 |
@classmethod
|
| 214 |
def fit(cls, frame: pd.DataFrame) -> TargetScaler:
|
| 215 |
+
"""Median and IQR per region, so one bad reading cannot skew the scaling."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
grouped = frame.groupby("ba_code")[TARGET]
|
| 217 |
centre = grouped.median()
|
| 218 |
spread = (grouped.quantile(0.75) - grouped.quantile(0.25)) / 1.349
|
|
|
|
| 240 |
return cls({k: (v[0], v[1]) for k, v in payload["stats"].items()})
|
| 241 |
|
| 242 |
|
|
|
|
|
|
|
|
|
|
| 243 |
@dataclass
|
| 244 |
class SeriesBundle:
|
| 245 |
"""Everything needed to build windows for a single balancing authority."""
|
| 246 |
|
| 247 |
ba_code: str
|
| 248 |
+
past: np.ndarray
|
| 249 |
+
future: np.ndarray
|
| 250 |
+
target_scaled: np.ndarray
|
| 251 |
+
target_raw: np.ndarray
|
| 252 |
timestamps: pd.DatetimeIndex
|
| 253 |
|
| 254 |
|
|
|
|
| 278 |
train_stride: int = TRAIN_STRIDE,
|
| 279 |
test_stride: int = TEST_STRIDE,
|
| 280 |
) -> tuple[ConcatDataset, ConcatDataset, list[tuple[SeriesBundle, np.ndarray]]]:
|
| 281 |
+
"""Put each training window into train, validation or test by its date.
|
| 282 |
|
| 283 |
+
A window only counts as test if all 24 forecast hours fall in the test period,
|
| 284 |
+
so nothing the model saw during training leaks into the score.
|
|
|
|
|
|
|
| 285 |
"""
|
| 286 |
train_sets, valid_sets, test_specs = [], [], []
|
| 287 |
|
|
|
|
| 327 |
return super().__getitem__(int(self.indices[index]))
|
| 328 |
|
| 329 |
|
|
|
|
|
|
|
|
|
|
| 330 |
@dataclass
|
| 331 |
class TrainedDeepModel:
|
| 332 |
architecture: str
|
|
|
|
| 440 |
|
| 441 |
optimiser = torch.optim.AdamW(model.parameters(), lr=2e-3, weight_decay=1e-4)
|
| 442 |
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimiser, factor=0.5, patience=2)
|
|
|
|
|
|
|
| 443 |
criterion = torch.nn.HuberLoss(delta=1.0)
|
| 444 |
|
| 445 |
max_epochs = 4 if quick else 15
|
|
|
|
| 498 |
|
| 499 |
|
| 500 |
def _predict_test(model, test_specs, target_scaler: TargetScaler, batch_size: int = 256) -> pd.DataFrame:
|
| 501 |
+
"""Predict every test window, averaging where windows overlap the same hour."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
torch = _torch()
|
| 503 |
model.eval()
|
| 504 |
rows = []
|
src/gridpulse/models/gbm.py
CHANGED
|
@@ -1,27 +1,4 @@
|
|
| 1 |
-
"""LightGBM
|
| 2 |
-
|
| 3 |
-
I train one model across all 12 regions, with the region code as a categorical
|
| 4 |
-
feature, instead of 12 separate models. Reasons:
|
| 5 |
-
|
| 6 |
-
* The regions behave similarly. How demand responds to temperature in Atlanta
|
| 7 |
-
really does tell you something about the same curve in Charlotte, so training
|
| 8 |
-
together helps the smaller regions.
|
| 9 |
-
* One model file is one thing to deploy, version and keep an eye on. Twelve
|
| 10 |
-
models are twelve of everything.
|
| 11 |
-
* Adding a thirteenth region is then just more data, not more infrastructure.
|
| 12 |
-
|
| 13 |
-
This only works if the demand is scaled first. The regions differ by a factor of
|
| 14 |
-
about a hundred (ISNE peaks around 25 GW, PJM around 150 GW), so if you train on
|
| 15 |
-
raw megawatthours the loss is completely dominated by the biggest regions. The
|
| 16 |
-
model spends all its effort on PJM and never learns the small ones. Scaling each
|
| 17 |
-
region separately puts them on a comparable footing, and predictions get converted
|
| 18 |
-
back to MW before anything is scored. Without this step the model gives up after a
|
| 19 |
-
handful of trees, which is exactly what happened before I added it.
|
| 20 |
-
|
| 21 |
-
As well as the single number forecast, three more models predict the 10th, 50th
|
| 22 |
-
and 90th percentiles. Utilities do not plan against a single number, they plan
|
| 23 |
-
against a realistic worst case, so the range is the more useful output.
|
| 24 |
-
"""
|
| 25 |
|
| 26 |
from __future__ import annotations
|
| 27 |
|
|
@@ -43,12 +20,7 @@ CATEGORICAL = ["ba_code"]
|
|
| 43 |
|
| 44 |
@dataclass
|
| 45 |
class BATargetScaler:
|
| 46 |
-
"""Scales demand separately for each region
|
| 47 |
-
|
| 48 |
-
The numbers are worked out from the training rows only, never the test rows.
|
| 49 |
-
``inverse`` converts predictions back into megawatthours, so every metric
|
| 50 |
-
downstream is reported in real units rather than scaled ones.
|
| 51 |
-
"""
|
| 52 |
|
| 53 |
stats: dict[str, tuple[float, float]]
|
| 54 |
global_mean: float
|
|
@@ -56,20 +28,9 @@ class BATargetScaler:
|
|
| 56 |
|
| 57 |
@classmethod
|
| 58 |
def fit(cls, frame: pd.DataFrame) -> BATargetScaler:
|
| 59 |
-
"""
|
| 60 |
-
|
| 61 |
-
I use the median and the IQR here instead of the mean and standard
|
| 62 |
-
deviation. One impossible reading can drag a mean anywhere it likes, and
|
| 63 |
-
the real EIA data does occasionally contain values that are wildly too
|
| 64 |
-
big. A single row like that pushed PJM's standard deviation up to 10.7
|
| 65 |
-
million MW when the real range is about 70,000 to 165,000 MW, which
|
| 66 |
-
wrecked the scaling and every prediction that depended on it. The median
|
| 67 |
-
does not move no matter how extreme one value is.
|
| 68 |
-
"""
|
| 69 |
grouped = frame.groupby("ba_code")[TARGET]
|
| 70 |
centre = grouped.median()
|
| 71 |
-
# Dividing the IQR by 1.349 puts it on roughly the same scale as a
|
| 72 |
-
# standard deviation would be, so the scaled values stay familiar.
|
| 73 |
spread = (grouped.quantile(0.75) - grouped.quantile(0.25)) / 1.349
|
| 74 |
|
| 75 |
stats = {
|
|
@@ -154,7 +115,6 @@ class TrainedGBM:
|
|
| 154 |
best_iteration: int
|
| 155 |
target_scaler: BATargetScaler
|
| 156 |
|
| 157 |
-
# -- persistence ------------------------------------------------------
|
| 158 |
def save(self, directory: Path | None = None) -> Path:
|
| 159 |
target = Path(directory) if directory else PATHS.artifacts / "gbm"
|
| 160 |
target.mkdir(parents=True, exist_ok=True)
|
|
@@ -196,7 +156,6 @@ class TrainedGBM:
|
|
| 196 |
target_scaler=BATargetScaler.from_dict(meta["target_scaler"]),
|
| 197 |
)
|
| 198 |
|
| 199 |
-
# -- inference --------------------------------------------------------
|
| 200 |
def predict(self, frame: pd.DataFrame) -> pd.DataFrame:
|
| 201 |
"""Point and quantile predictions, returned in megawatthours.
|
| 202 |
|
|
@@ -212,7 +171,6 @@ class TrainedGBM:
|
|
| 212 |
out[f"pred_gbm_p{int(q * 100)}"] = self.target_scaler.inverse(
|
| 213 |
model.predict(matrix), ba_codes
|
| 214 |
)
|
| 215 |
-
# Quantile models are fit independently and can cross; enforce monotonicity.
|
| 216 |
quantile_columns = [c for c in out.columns if c.startswith("pred_gbm_p")]
|
| 217 |
out[quantile_columns] = np.sort(out[quantile_columns].to_numpy(), axis=1)
|
| 218 |
return out
|
|
@@ -257,8 +215,6 @@ def train_gbm(
|
|
| 257 |
ba_categories = sorted(pd.concat([train["ba_code"], valid["ba_code"]]).unique().tolist())
|
| 258 |
features = list(features) if features else list(FEATURE_COLUMNS)
|
| 259 |
|
| 260 |
-
# Fitted on training rows only, so no validation information leaks into the
|
| 261 |
-
# normalisation constants.
|
| 262 |
target_scaler = BATargetScaler.fit(train)
|
| 263 |
|
| 264 |
x_train = prepare_matrix(train, ba_categories, features)
|
|
@@ -286,9 +242,6 @@ def train_gbm(
|
|
| 286 |
)
|
| 287 |
logger.info(" point model stopped at iteration %d", point.best_iteration)
|
| 288 |
|
| 289 |
-
# Quantile models are three additional fits. They are capped well below the
|
| 290 |
-
# point model's round count: interval edges need far less resolution than the
|
| 291 |
-
# central estimate, and the extra rounds cost real minutes on a CPU.
|
| 292 |
quantile_rounds = int(min(max(200, point.best_iteration), 250 if quick else 700))
|
| 293 |
quantile_models: dict[float, object] = {}
|
| 294 |
for q in quantiles or ():
|
|
|
|
| 1 |
+
"""One LightGBM model across all 12 regions, plus P10/P50/P90 prediction bands."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 20 |
|
| 21 |
@dataclass
|
| 22 |
class BATargetScaler:
|
| 23 |
+
"""Scales demand separately for each region so they can share one model."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
stats: dict[str, tuple[float, float]]
|
| 26 |
global_mean: float
|
|
|
|
| 28 |
|
| 29 |
@classmethod
|
| 30 |
def fit(cls, frame: pd.DataFrame) -> BATargetScaler:
|
| 31 |
+
"""Median and IQR per region, so one bad reading cannot skew the scaling."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
grouped = frame.groupby("ba_code")[TARGET]
|
| 33 |
centre = grouped.median()
|
|
|
|
|
|
|
| 34 |
spread = (grouped.quantile(0.75) - grouped.quantile(0.25)) / 1.349
|
| 35 |
|
| 36 |
stats = {
|
|
|
|
| 115 |
best_iteration: int
|
| 116 |
target_scaler: BATargetScaler
|
| 117 |
|
|
|
|
| 118 |
def save(self, directory: Path | None = None) -> Path:
|
| 119 |
target = Path(directory) if directory else PATHS.artifacts / "gbm"
|
| 120 |
target.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 156 |
target_scaler=BATargetScaler.from_dict(meta["target_scaler"]),
|
| 157 |
)
|
| 158 |
|
|
|
|
| 159 |
def predict(self, frame: pd.DataFrame) -> pd.DataFrame:
|
| 160 |
"""Point and quantile predictions, returned in megawatthours.
|
| 161 |
|
|
|
|
| 171 |
out[f"pred_gbm_p{int(q * 100)}"] = self.target_scaler.inverse(
|
| 172 |
model.predict(matrix), ba_codes
|
| 173 |
)
|
|
|
|
| 174 |
quantile_columns = [c for c in out.columns if c.startswith("pred_gbm_p")]
|
| 175 |
out[quantile_columns] = np.sort(out[quantile_columns].to_numpy(), axis=1)
|
| 176 |
return out
|
|
|
|
| 215 |
ba_categories = sorted(pd.concat([train["ba_code"], valid["ba_code"]]).unique().tolist())
|
| 216 |
features = list(features) if features else list(FEATURE_COLUMNS)
|
| 217 |
|
|
|
|
|
|
|
| 218 |
target_scaler = BATargetScaler.fit(train)
|
| 219 |
|
| 220 |
x_train = prepare_matrix(train, ba_categories, features)
|
|
|
|
| 242 |
)
|
| 243 |
logger.info(" point model stopped at iteration %d", point.best_iteration)
|
| 244 |
|
|
|
|
|
|
|
|
|
|
| 245 |
quantile_rounds = int(min(max(200, point.best_iteration), 250 if quick else 700))
|
| 246 |
quantile_models: dict[float, object] = {}
|
| 247 |
for q in quantiles or ():
|
src/gridpulse/models/inference.py
CHANGED
|
@@ -1,19 +1,7 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
1. **Recent observed demand** for the autoregressive lags, from the warehouse and
|
| 7 |
-
topped up from the live EIA API when the stored copy is stale.
|
| 8 |
-
2. **Future weather**, fetched live from Open-Meteo. This is legitimate: a system
|
| 9 |
-
operator producing a day-ahead forecast genuinely holds tomorrow's numerical
|
| 10 |
-
weather prediction.
|
| 11 |
-
3. **A trained model**, loaded from the committed artifacts so no retraining
|
| 12 |
-
happens on the request path.
|
| 13 |
-
|
| 14 |
-
When the network is unavailable the module degrades to *replay* mode: it forecasts
|
| 15 |
-
the most recent 24 hours that already exist in the warehouse, so the public site
|
| 16 |
-
still demonstrates the model instead of showing an error page.
|
| 17 |
"""
|
| 18 |
|
| 19 |
from __future__ import annotations
|
|
@@ -30,15 +18,15 @@ from gridpulse.features.build import build_features
|
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
| 32 |
|
| 33 |
-
HISTORY_HOURS = 400
|
| 34 |
|
| 35 |
|
| 36 |
@dataclass
|
| 37 |
class Forecast:
|
| 38 |
ba_code: str
|
| 39 |
generated_at_utc: datetime
|
| 40 |
-
mode: str
|
| 41 |
-
frame: pd.DataFrame
|
| 42 |
model: str = "gbm"
|
| 43 |
notes: list[str] = None
|
| 44 |
|
|
@@ -135,9 +123,6 @@ def forecast(ba_code: str, horizon: int = FORECAST_HORIZON, allow_network: bool
|
|
| 135 |
history["period_utc"] = pd.to_datetime(history["period_utc"], utc=True)
|
| 136 |
last_observed = history["period_utc"].max()
|
| 137 |
|
| 138 |
-
# ------------------------------------------------------------------
|
| 139 |
-
# Assemble the future rows
|
| 140 |
-
# ------------------------------------------------------------------
|
| 141 |
mode = "replay"
|
| 142 |
future = pd.DataFrame()
|
| 143 |
|
|
@@ -168,22 +153,14 @@ def forecast(ba_code: str, horizon: int = FORECAST_HORIZON, allow_network: bool
|
|
| 168 |
combined = pd.concat([history, future], ignore_index=True)
|
| 169 |
target_periods = future["period_utc"]
|
| 170 |
else:
|
| 171 |
-
# Replay: hide the final `horizon` actuals from the feature builder and
|
| 172 |
-
# predict them, so the chart still shows prediction against truth.
|
| 173 |
combined = history.copy()
|
| 174 |
target_periods = combined["period_utc"].tail(horizon)
|
| 175 |
combined.loc[combined["period_utc"].isin(target_periods), "demand_mwh"] = np.nan
|
| 176 |
|
| 177 |
combined = combined.sort_values("period_utc").reset_index(drop=True)
|
| 178 |
|
| 179 |
-
# Calendar attributes are derived here for every row, historical and future
|
| 180 |
-
# alike, rather than read from storage. Storage and derivation would otherwise
|
| 181 |
-
# be two sources of truth for the same values.
|
| 182 |
combined = _calendar_columns(combined, BALANCING_AUTHORITIES[ba_code].timezone)
|
| 183 |
|
| 184 |
-
# ------------------------------------------------------------------
|
| 185 |
-
# Features and prediction
|
| 186 |
-
# ------------------------------------------------------------------
|
| 187 |
featured = build_features(frame=combined, dropna_target=False)
|
| 188 |
horizon_rows = featured[featured["period_utc"].isin(target_periods)].copy()
|
| 189 |
if horizon_rows.empty:
|
|
|
|
| 1 |
+
"""Makes a real forward-looking 24-hour forecast for the live app.
|
| 2 |
+
|
| 3 |
+
Falls back to replaying the most recent stored day when the network is down, so
|
| 4 |
+
the public site still shows something instead of an error.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
|
|
|
| 18 |
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
+
HISTORY_HOURS = 400
|
| 22 |
|
| 23 |
|
| 24 |
@dataclass
|
| 25 |
class Forecast:
|
| 26 |
ba_code: str
|
| 27 |
generated_at_utc: datetime
|
| 28 |
+
mode: str
|
| 29 |
+
frame: pd.DataFrame
|
| 30 |
model: str = "gbm"
|
| 31 |
notes: list[str] = None
|
| 32 |
|
|
|
|
| 123 |
history["period_utc"] = pd.to_datetime(history["period_utc"], utc=True)
|
| 124 |
last_observed = history["period_utc"].max()
|
| 125 |
|
|
|
|
|
|
|
|
|
|
| 126 |
mode = "replay"
|
| 127 |
future = pd.DataFrame()
|
| 128 |
|
|
|
|
| 153 |
combined = pd.concat([history, future], ignore_index=True)
|
| 154 |
target_periods = future["period_utc"]
|
| 155 |
else:
|
|
|
|
|
|
|
| 156 |
combined = history.copy()
|
| 157 |
target_periods = combined["period_utc"].tail(horizon)
|
| 158 |
combined.loc[combined["period_utc"].isin(target_periods), "demand_mwh"] = np.nan
|
| 159 |
|
| 160 |
combined = combined.sort_values("period_utc").reset_index(drop=True)
|
| 161 |
|
|
|
|
|
|
|
|
|
|
| 162 |
combined = _calendar_columns(combined, BALANCING_AUTHORITIES[ba_code].timezone)
|
| 163 |
|
|
|
|
|
|
|
|
|
|
| 164 |
featured = build_features(frame=combined, dropna_target=False)
|
| 165 |
horizon_rows = featured[featured["period_utc"].isin(target_periods)].copy()
|
| 166 |
if horizon_rows.empty:
|
src/gridpulse/models/metrics.py
CHANGED
|
@@ -1,15 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
MAPE is what the power industry normally uses, so it comes first here. Someone who
|
| 4 |
-
runs a grid will say "we run about 2 percent MAPE" and everyone knows what that
|
| 5 |
-
means. But I report MAE and RMSE next to it, because MAPE on its own hides the fact
|
| 6 |
-
that some mistakes cost much more than others. RMSE punishes the big misses, which
|
| 7 |
-
are the ones that force an expensive backup plant to start up, while MAPE treats
|
| 8 |
-
being 500 MW off at 3am the same as being 500 MW off at 5pm in a heatwave.
|
| 9 |
-
|
| 10 |
-
``skill_vs_benchmark`` turns the accuracy into a percentage improvement over EIA's
|
| 11 |
-
own published forecast, which is the comparison that actually matters here.
|
| 12 |
-
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
| 15 |
|
|
@@ -61,12 +50,7 @@ def r2(y_true, y_pred) -> float:
|
|
| 61 |
|
| 62 |
|
| 63 |
def peak_hour_mape(frame: pd.DataFrame, actual: str, predicted: str) -> float:
|
| 64 |
-
"""MAPE calculated only on the busiest hour of each day.
|
| 65 |
-
|
| 66 |
-
The peak hour is what decides how much generation gets bought, and it is where
|
| 67 |
-
being wrong costs the most money, so I score it separately from the average
|
| 68 |
-
across all hours.
|
| 69 |
-
"""
|
| 70 |
if frame.empty:
|
| 71 |
return float("nan")
|
| 72 |
peaks = frame.loc[frame.groupby(frame["period_utc"].dt.date)[actual].idxmax()]
|
|
|
|
| 1 |
+
"""Forecast accuracy metrics: MAPE, sMAPE, MAE, RMSE, R2, pinball loss and skill."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
def peak_hour_mape(frame: pd.DataFrame, actual: str, predicted: str) -> float:
|
| 53 |
+
"""MAPE calculated only on the busiest hour of each day."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
if frame.empty:
|
| 55 |
return float("nan")
|
| 56 |
peaks = frame.loc[frame.groupby(frame["period_utc"].dt.date)[actual].idxmax()]
|
src/gridpulse/models/pipeline.py
CHANGED
|
@@ -1,20 +1,7 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
I made up. It is the day-ahead forecast the EIA published and grid operators
|
| 6 |
-
actually used:
|
| 7 |
-
|
| 8 |
-
1. Seasonal naive, meaning "same hour yesterday"
|
| 9 |
-
2. Weekly naive, meaning "same hour last week"
|
| 10 |
-
3. **EIA's official day-ahead forecast** <- the one to beat
|
| 11 |
-
4. LightGBM trained on all regions, with P10/P50/P90 bands
|
| 12 |
-
5. LSTM, which also gets tomorrow's weather and calendar
|
| 13 |
-
6. Transformer, same inputs as the LSTM
|
| 14 |
-
|
| 15 |
-
Every model is scored on the same rows, over the same 24 hours ahead, with the same
|
| 16 |
-
metrics. The results go into ``model_scores`` and ``model_predictions``, and each
|
| 17 |
-
run is logged to MLflow.
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
|
@@ -53,9 +40,6 @@ def train_all(bas: list[str] | None = None, quick: bool = False) -> pd.DataFrame
|
|
| 53 |
mlflow.set_tracking_uri("file:" + str((PATHS.artifacts.parent / "mlruns").as_posix()))
|
| 54 |
mlflow.set_experiment("gridpulse-day-ahead-load")
|
| 55 |
|
| 56 |
-
# ------------------------------------------------------------------
|
| 57 |
-
# Features and the single shared time split
|
| 58 |
-
# ------------------------------------------------------------------
|
| 59 |
logger.info("Building feature matrix")
|
| 60 |
frame = build_features(ba_codes=bas)
|
| 61 |
if frame.empty:
|
|
@@ -77,9 +61,6 @@ def train_all(bas: list[str] | None = None, quick: bool = False) -> pd.DataFrame
|
|
| 77 |
|
| 78 |
predictions = test[["period_utc", "ba_code", "demand_mwh", "demand_forecast_mwh"]].copy()
|
| 79 |
|
| 80 |
-
# ------------------------------------------------------------------
|
| 81 |
-
# 1-3. Baselines and the EIA benchmark
|
| 82 |
-
# ------------------------------------------------------------------
|
| 83 |
logger.info("Scoring baselines")
|
| 84 |
with_baselines = baselines.build_all_baselines(frame)
|
| 85 |
baseline_test = with_baselines[with_baselines["period_utc"] >= test_start]
|
|
@@ -87,9 +68,6 @@ def train_all(bas: list[str] | None = None, quick: bool = False) -> pd.DataFrame
|
|
| 87 |
if column in baseline_test.columns:
|
| 88 |
predictions[column] = baseline_test[column].to_numpy()
|
| 89 |
|
| 90 |
-
# ------------------------------------------------------------------
|
| 91 |
-
# 4. LightGBM
|
| 92 |
-
# ------------------------------------------------------------------
|
| 93 |
logger.info("Training LightGBM")
|
| 94 |
from gridpulse.models.gbm import train_gbm
|
| 95 |
|
|
@@ -99,19 +77,6 @@ def train_all(bas: list[str] | None = None, quick: bool = False) -> pd.DataFrame
|
|
| 99 |
predictions[column] = gbm_predictions[column].to_numpy()
|
| 100 |
gbm.save()
|
| 101 |
|
| 102 |
-
# ------------------------------------------------------------------
|
| 103 |
-
# 4b. Hybrid version: the same model, but it also gets to read EIA's
|
| 104 |
-
# published day-ahead forecast as one of its inputs.
|
| 105 |
-
#
|
| 106 |
-
# This is not cheating. EIA publishes that forecast the day before, so it
|
| 107 |
-
# really is available at prediction time, and no real utility ignores a
|
| 108 |
-
# forecast they already have. This version learns to correct the mistakes EIA
|
| 109 |
-
# consistently makes, instead of working the whole thing out from scratch,
|
| 110 |
-
# which is closer to how a real forecasting team works anyway.
|
| 111 |
-
#
|
| 112 |
-
# I report both. The plain model answers "can I beat them starting from
|
| 113 |
-
# nothing", and the hybrid answers "can I improve on what they publish".
|
| 114 |
-
# ------------------------------------------------------------------
|
| 115 |
if train["demand_forecast_mwh"].notna().mean() > 0.9:
|
| 116 |
logger.info("Training LightGBM hybrid (EIA forecast as an input feature)")
|
| 117 |
from gridpulse.features.build import FEATURE_COLUMNS
|
|
@@ -129,9 +94,6 @@ def train_all(bas: list[str] | None = None, quick: bool = False) -> pd.DataFrame
|
|
| 129 |
)
|
| 130 |
logger.info("Top features:\n%s", importance.head(12).to_string(index=False))
|
| 131 |
|
| 132 |
-
# ------------------------------------------------------------------
|
| 133 |
-
# 5-6. Deep models
|
| 134 |
-
# ------------------------------------------------------------------
|
| 135 |
for architecture in ("lstm", "transformer"):
|
| 136 |
try:
|
| 137 |
logger.info("Training deep model: %s", architecture)
|
|
@@ -154,19 +116,12 @@ def train_all(bas: list[str] | None = None, quick: bool = False) -> pd.DataFrame
|
|
| 154 |
except Exception as exc: # noqa: BLE001
|
| 155 |
logger.error("Deep model %s failed: %s", architecture, exc, exc_info=True)
|
| 156 |
|
| 157 |
-
# ------------------------------------------------------------------
|
| 158 |
-
# Ensemble: simple average of the two strongest model families
|
| 159 |
-
# ------------------------------------------------------------------
|
| 160 |
ensemble_parts = [c for c in ("pred_gbm", "pred_lstm") if c in predictions.columns]
|
| 161 |
if len(ensemble_parts) > 1:
|
| 162 |
predictions["pred_ensemble"] = predictions[ensemble_parts].mean(axis=1)
|
| 163 |
|
| 164 |
-
# ------------------------------------------------------------------
|
| 165 |
-
# Scoring
|
| 166 |
-
# ------------------------------------------------------------------
|
| 167 |
leaderboard = _score(predictions)
|
| 168 |
|
| 169 |
-
# Interval calibration, reported only if the quantile models produced output.
|
| 170 |
if {"pred_gbm_p10", "pred_gbm_p90"} <= set(predictions.columns):
|
| 171 |
interval_coverage = metrics.coverage(
|
| 172 |
predictions["demand_mwh"], predictions["pred_gbm_p10"], predictions["pred_gbm_p90"]
|
|
|
|
| 1 |
+
"""Builds the features, trains every model, and scores them all on the same rows.
|
| 2 |
+
|
| 3 |
+
The benchmark is EIA's own published day-ahead forecast, not a baseline invented
|
| 4 |
+
here. Results go to ``model_scores`` and ``model_predictions``, and to MLflow.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
|
|
|
| 40 |
mlflow.set_tracking_uri("file:" + str((PATHS.artifacts.parent / "mlruns").as_posix()))
|
| 41 |
mlflow.set_experiment("gridpulse-day-ahead-load")
|
| 42 |
|
|
|
|
|
|
|
|
|
|
| 43 |
logger.info("Building feature matrix")
|
| 44 |
frame = build_features(ba_codes=bas)
|
| 45 |
if frame.empty:
|
|
|
|
| 61 |
|
| 62 |
predictions = test[["period_utc", "ba_code", "demand_mwh", "demand_forecast_mwh"]].copy()
|
| 63 |
|
|
|
|
|
|
|
|
|
|
| 64 |
logger.info("Scoring baselines")
|
| 65 |
with_baselines = baselines.build_all_baselines(frame)
|
| 66 |
baseline_test = with_baselines[with_baselines["period_utc"] >= test_start]
|
|
|
|
| 68 |
if column in baseline_test.columns:
|
| 69 |
predictions[column] = baseline_test[column].to_numpy()
|
| 70 |
|
|
|
|
|
|
|
|
|
|
| 71 |
logger.info("Training LightGBM")
|
| 72 |
from gridpulse.models.gbm import train_gbm
|
| 73 |
|
|
|
|
| 77 |
predictions[column] = gbm_predictions[column].to_numpy()
|
| 78 |
gbm.save()
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
if train["demand_forecast_mwh"].notna().mean() > 0.9:
|
| 81 |
logger.info("Training LightGBM hybrid (EIA forecast as an input feature)")
|
| 82 |
from gridpulse.features.build import FEATURE_COLUMNS
|
|
|
|
| 94 |
)
|
| 95 |
logger.info("Top features:\n%s", importance.head(12).to_string(index=False))
|
| 96 |
|
|
|
|
|
|
|
|
|
|
| 97 |
for architecture in ("lstm", "transformer"):
|
| 98 |
try:
|
| 99 |
logger.info("Training deep model: %s", architecture)
|
|
|
|
| 116 |
except Exception as exc: # noqa: BLE001
|
| 117 |
logger.error("Deep model %s failed: %s", architecture, exc, exc_info=True)
|
| 118 |
|
|
|
|
|
|
|
|
|
|
| 119 |
ensemble_parts = [c for c in ("pred_gbm", "pred_lstm") if c in predictions.columns]
|
| 120 |
if len(ensemble_parts) > 1:
|
| 121 |
predictions["pred_ensemble"] = predictions[ensemble_parts].mean(axis=1)
|
| 122 |
|
|
|
|
|
|
|
|
|
|
| 123 |
leaderboard = _score(predictions)
|
| 124 |
|
|
|
|
| 125 |
if {"pred_gbm_p10", "pred_gbm_p90"} <= set(predictions.columns):
|
| 126 |
interval_coverage = metrics.coverage(
|
| 127 |
predictions["demand_mwh"], predictions["pred_gbm_p10"], predictions["pred_gbm_p90"]
|
src/gridpulse/quality/checks.py
CHANGED
|
@@ -1,17 +1,5 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Electricity meter data goes wrong in ways that general purpose testing tools do not
|
| 4 |
-
look for. A meter reporting the exact same value for six hours is not steady, it is
|
| 5 |
-
stuck. An hour that disappears every March is not missing data, it is the clocks
|
| 6 |
-
going forward. A negative demand reading does not mean the grid was quiet, it means
|
| 7 |
-
someone got a plus and minus the wrong way round further upstream. Every check
|
| 8 |
-
below is written around one of those specific problems.
|
| 9 |
-
|
| 10 |
-
Each check is scored against one of six categories: completeness, validity,
|
| 11 |
-
consistency, timeliness, duplicates and accuracy. The results get saved into
|
| 12 |
-
``dq_results`` and ``dq_scorecard`` rather than just printed, so I can look back at
|
| 13 |
-
how data quality changed over time instead of only seeing today's answer.
|
| 14 |
-
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
|
@@ -28,9 +16,9 @@ logger = logging.getLogger(__name__)
|
|
| 28 |
|
| 29 |
|
| 30 |
class Severity(str, Enum):
|
| 31 |
-
CRITICAL = "critical"
|
| 32 |
-
WARNING = "warning"
|
| 33 |
-
INFO = "info"
|
| 34 |
|
| 35 |
|
| 36 |
class Dimension(str, Enum):
|
|
@@ -44,18 +32,15 @@ class Dimension(str, Enum):
|
|
| 44 |
|
| 45 |
@dataclass(frozen=True)
|
| 46 |
class Check:
|
| 47 |
-
"""
|
| 48 |
-
|
| 49 |
-
``sql`` must return exactly one row with columns ``failed`` and ``total``.
|
| 50 |
-
The check passes when ``failed / total <= threshold``.
|
| 51 |
-
"""
|
| 52 |
|
| 53 |
name: str
|
| 54 |
dimension: Dimension
|
| 55 |
severity: Severity
|
| 56 |
description: str
|
| 57 |
sql: str
|
| 58 |
-
threshold: float = 0.0
|
| 59 |
|
| 60 |
|
| 61 |
@dataclass
|
|
@@ -117,9 +102,6 @@ class QualityReport:
|
|
| 117 |
)
|
| 118 |
|
| 119 |
|
| 120 |
-
# ---------------------------------------------------------------------------
|
| 121 |
-
# The suite
|
| 122 |
-
# ---------------------------------------------------------------------------
|
| 123 |
CHECKS: list[Check] = [
|
| 124 |
Check(
|
| 125 |
name="demand_not_null",
|
|
@@ -127,7 +109,7 @@ CHECKS: list[Check] = [
|
|
| 127 |
severity=Severity.WARNING,
|
| 128 |
description="Actual demand is reported for every hour on the spine.",
|
| 129 |
sql="SELECT count(*) FILTER (WHERE demand_mwh IS NULL) AS failed, count(*) AS total FROM fact_demand_hourly",
|
| 130 |
-
threshold=0.02,
|
| 131 |
),
|
| 132 |
Check(
|
| 133 |
name="demand_positive",
|
|
@@ -316,20 +298,7 @@ CHECKS: list[Check] = [
|
|
| 316 |
|
| 317 |
|
| 318 |
def run_quality_suite(persist: bool = True, database=None) -> QualityReport:
|
| 319 |
-
"""
|
| 320 |
-
|
| 321 |
-
Parameters
|
| 322 |
-
----------
|
| 323 |
-
persist
|
| 324 |
-
Write results to ``dq_results`` and rebuild ``dq_scorecard``.
|
| 325 |
-
database
|
| 326 |
-
Override the warehouse path. Used by the test suite.
|
| 327 |
-
|
| 328 |
-
Returns
|
| 329 |
-
-------
|
| 330 |
-
QualityReport
|
| 331 |
-
``report.passed`` is False when any CRITICAL check failed.
|
| 332 |
-
"""
|
| 333 |
report = QualityReport()
|
| 334 |
|
| 335 |
with connect(database, read_only=not persist) as con:
|
|
|
|
| 1 |
+
"""Sixteen data quality checks, scored across six categories and saved to the
|
| 2 |
+
warehouse so quality can be tracked over time rather than only printed."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
class Severity(str, Enum):
|
| 19 |
+
CRITICAL = "critical"
|
| 20 |
+
WARNING = "warning"
|
| 21 |
+
INFO = "info"
|
| 22 |
|
| 23 |
|
| 24 |
class Dimension(str, Enum):
|
|
|
|
| 32 |
|
| 33 |
@dataclass(frozen=True)
|
| 34 |
class Check:
|
| 35 |
+
"""One check. Its SQL returns ``failed`` and ``total``, and it passes when
|
| 36 |
+
``failed / total`` is within the threshold."""
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
name: str
|
| 39 |
dimension: Dimension
|
| 40 |
severity: Severity
|
| 41 |
description: str
|
| 42 |
sql: str
|
| 43 |
+
threshold: float = 0.0
|
| 44 |
|
| 45 |
|
| 46 |
@dataclass
|
|
|
|
| 102 |
)
|
| 103 |
|
| 104 |
|
|
|
|
|
|
|
|
|
|
| 105 |
CHECKS: list[Check] = [
|
| 106 |
Check(
|
| 107 |
name="demand_not_null",
|
|
|
|
| 109 |
severity=Severity.WARNING,
|
| 110 |
description="Actual demand is reported for every hour on the spine.",
|
| 111 |
sql="SELECT count(*) FILTER (WHERE demand_mwh IS NULL) AS failed, count(*) AS total FROM fact_demand_hourly",
|
| 112 |
+
threshold=0.02,
|
| 113 |
),
|
| 114 |
Check(
|
| 115 |
name="demand_positive",
|
|
|
|
| 298 |
|
| 299 |
|
| 300 |
def run_quality_suite(persist: bool = True, database=None) -> QualityReport:
|
| 301 |
+
"""Run every check. ``report.passed`` is False if any critical one failed."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
report = QualityReport()
|
| 303 |
|
| 304 |
with connect(database, read_only=not persist) as con:
|
src/gridpulse/warehouse/build.py
CHANGED
|
@@ -1,26 +1,7 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
**Bronze**
|
| 6 |
-
The raw data exactly as it was downloaded, one row per region per hour per
|
| 7 |
-
measurement. I never edit it, only add to it, so if something goes wrong later
|
| 8 |
-
I can always go back and reproduce it.
|
| 9 |
-
|
| 10 |
-
**Silver**
|
| 11 |
-
Cleaned up and reshaped. The measurements become columns, the weather gets
|
| 12 |
-
joined on, a full list of every hour makes any missing hours obvious, local
|
| 13 |
-
time is worked out for each region, and anything suspicious is *flagged rather
|
| 14 |
-
than deleted*. Deleting a bad reading also deletes the proof that a meter was
|
| 15 |
-
broken, and that proof is the useful part.
|
| 16 |
-
|
| 17 |
-
**Gold**
|
| 18 |
-
A star schema, which is what the dashboard and the models read from. It has
|
| 19 |
-
lookup tables around `fact_demand_hourly`, plus a separate table that scores
|
| 20 |
-
EIA's own published forecast against what actually happened.
|
| 21 |
-
|
| 22 |
-
All the heavy work is done in SQL over whole tables at once, rather than looping
|
| 23 |
-
through rows in pandas, so memory stays flat no matter how much history there is.
|
| 24 |
"""
|
| 25 |
|
| 26 |
from __future__ import annotations
|
|
@@ -34,23 +15,10 @@ from gridpulse.warehouse.duck import connect, row_count, table_exists
|
|
| 34 |
|
| 35 |
logger = logging.getLogger(__name__)
|
| 36 |
|
| 37 |
-
# Physically implausible demand. Real BA demand never legitimately hits zero;
|
| 38 |
-
# a zero or negative reading is a telemetry failure, not a quiet grid.
|
| 39 |
MIN_PLAUSIBLE_MWH = 1.0
|
| 40 |
-
# Hour-on-hour swings beyond this are almost always bad data, not real load.
|
| 41 |
MAX_HOURLY_RAMP_PCT = 40.0
|
| 42 |
|
| 43 |
-
|
| 44 |
-
# rather than against the point before and after. Comparing to neighbours broke in
|
| 45 |
-
# two ways when I tried it. It needs a threshold low enough to catch a spike that
|
| 46 |
-
# is only extreme on one side, but high enough not to flag genuine fast changes.
|
| 47 |
-
# And it cannot judge the very first or very last row at all, which is exactly
|
| 48 |
-
# where the newest and least reliable data sits.
|
| 49 |
-
#
|
| 50 |
-
# A rolling median has neither problem. Total demand moves smoothly over five
|
| 51 |
-
# hours, so a normal day never strays far from its local median, while a single
|
| 52 |
-
# bad reading stands out no matter which side it falls on.
|
| 53 |
-
SPIKE_WINDOW_HOURS = 2 # rows either side, so a 5 hour window centred on each point
|
| 54 |
SPIKE_DEVIATION_PCT = 20.0
|
| 55 |
|
| 56 |
|
|
@@ -100,7 +68,7 @@ def _dim_date_frame(start: pd.Timestamp, end: pd.Timestamp) -> pd.DataFrame:
|
|
| 100 |
frame["quarter"] = days.quarter
|
| 101 |
frame["month"] = days.month
|
| 102 |
frame["day_of_month"] = days.day
|
| 103 |
-
frame["day_of_week"] = days.dayofweek
|
| 104 |
frame["day_of_year"] = days.dayofyear
|
| 105 |
frame["week_of_year"] = days.isocalendar().week.astype(int)
|
| 106 |
frame["is_weekend"] = days.dayofweek >= 5
|
|
@@ -151,10 +119,6 @@ def build_warehouse(rebuild: bool = False) -> dict[str, int]:
|
|
| 151 |
|
| 152 |
with connect() as con:
|
| 153 |
if rebuild:
|
| 154 |
-
# Only the tables this function owns are dropped. Model scores,
|
| 155 |
-
# predictions and anomaly results belong to other commands; wiping
|
| 156 |
-
# them here would silently discard an hour of training because
|
| 157 |
-
# someone rebuilt the data layer.
|
| 158 |
logger.info("Rebuild requested: dropping data-layer tables")
|
| 159 |
for table in (
|
| 160 |
"fact_forecast_accuracy", "fact_demand_hourly",
|
|
@@ -173,17 +137,11 @@ def build_warehouse(rebuild: bool = False) -> dict[str, int]:
|
|
| 173 |
", ".join(stale),
|
| 174 |
)
|
| 175 |
|
| 176 |
-
# ------------------------------------------------------------------
|
| 177 |
-
# Dimensions
|
| 178 |
-
# ------------------------------------------------------------------
|
| 179 |
logger.info("Building dim_ba")
|
| 180 |
con.register("_dim_ba", _dim_ba_frame())
|
| 181 |
con.execute("CREATE OR REPLACE TABLE dim_ba AS SELECT * FROM _dim_ba")
|
| 182 |
con.unregister("_dim_ba")
|
| 183 |
|
| 184 |
-
# ------------------------------------------------------------------
|
| 185 |
-
# Silver: pivot measures, attach weather, derive local civil time
|
| 186 |
-
# ------------------------------------------------------------------
|
| 187 |
logger.info("Building silver_grid_hourly")
|
| 188 |
con.execute(f"""
|
| 189 |
CREATE OR REPLACE TEMP TABLE _eia_wide AS
|
|
@@ -207,11 +165,6 @@ def build_warehouse(rebuild: bool = False) -> dict[str, int]:
|
|
| 207 |
weather_join = ""
|
| 208 |
weather_select = weather_nulls
|
| 209 |
|
| 210 |
-
# A dense hourly spine per BA. Anti-joining against it is the only reliable
|
| 211 |
-
# way to distinguish "reported zero" from "never reported at all".
|
| 212 |
-
# Built in pandas rather than SQL: generate_series over TIMESTAMPTZ depends
|
| 213 |
-
# on the ICU extension and has shifted across DuckDB releases, whereas
|
| 214 |
-
# date_range is deterministic on every platform.
|
| 215 |
bounds = con.execute(
|
| 216 |
"SELECT ba_code, min(period_utc) AS lo, max(period_utc) AS hi "
|
| 217 |
"FROM _eia_wide GROUP BY ba_code"
|
|
@@ -266,9 +219,6 @@ def build_warehouse(rebuild: bool = False) -> dict[str, int]:
|
|
| 266 |
ORDER BY s.ba_code, s.period_utc
|
| 267 |
""")
|
| 268 |
|
| 269 |
-
# ------------------------------------------------------------------
|
| 270 |
-
# Gold: calendar dimension sized to the observed data
|
| 271 |
-
# ------------------------------------------------------------------
|
| 272 |
span = con.execute(
|
| 273 |
"SELECT min(date_local), max(date_local) FROM silver_grid_hourly"
|
| 274 |
).fetchone()
|
|
@@ -283,12 +233,7 @@ def build_warehouse(rebuild: bool = False) -> dict[str, int]:
|
|
| 283 |
)
|
| 284 |
con.unregister("_dim_date")
|
| 285 |
|
| 286 |
-
# ------------------------------------------------------------------
|
| 287 |
-
# Gold: central fact
|
| 288 |
-
# ------------------------------------------------------------------
|
| 289 |
logger.info("Building fact_demand_hourly")
|
| 290 |
-
# Robust per-BA bounds. Computed from the median so that the outliers being
|
| 291 |
-
# detected cannot influence the threshold that detects them.
|
| 292 |
con.execute("""
|
| 293 |
CREATE OR REPLACE TEMP TABLE _ba_bounds AS
|
| 294 |
SELECT ba_code,
|
|
@@ -300,8 +245,6 @@ def build_warehouse(rebuild: bool = False) -> dict[str, int]:
|
|
| 300 |
GROUP BY ba_code
|
| 301 |
""")
|
| 302 |
|
| 303 |
-
# Local level for spike detection, computed once so the window function is
|
| 304 |
-
# not repeated across several expressions.
|
| 305 |
con.execute("""
|
| 306 |
CREATE OR REPLACE TEMP TABLE _neighbours AS
|
| 307 |
SELECT
|
|
@@ -367,10 +310,6 @@ def build_warehouse(rebuild: bool = False) -> dict[str, int]:
|
|
| 367 |
ORDER BY s.ba_code, s.period_utc
|
| 368 |
""")
|
| 369 |
|
| 370 |
-
# ------------------------------------------------------------------
|
| 371 |
-
# Gold: the benchmark table. This is the one that holds EIA's own forecast
|
| 372 |
-
# next to what actually happened, so anyone can check my headline claim.
|
| 373 |
-
# ------------------------------------------------------------------
|
| 374 |
logger.info("Building fact_forecast_accuracy")
|
| 375 |
con.execute("""
|
| 376 |
CREATE OR REPLACE TABLE fact_forecast_accuracy AS
|
|
|
|
| 1 |
+
"""Builds the warehouse in DuckDB: raw bronze, cleaned silver, star schema gold.
|
| 2 |
|
| 3 |
+
Bad readings are flagged rather than deleted, because deleting them also deletes
|
| 4 |
+
the evidence that a meter was broken.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
|
|
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
|
|
|
|
|
|
| 18 |
MIN_PLAUSIBLE_MWH = 1.0
|
|
|
|
| 19 |
MAX_HOURLY_RAMP_PCT = 40.0
|
| 20 |
|
| 21 |
+
SPIKE_WINDOW_HOURS = 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
SPIKE_DEVIATION_PCT = 20.0
|
| 23 |
|
| 24 |
|
|
|
|
| 68 |
frame["quarter"] = days.quarter
|
| 69 |
frame["month"] = days.month
|
| 70 |
frame["day_of_month"] = days.day
|
| 71 |
+
frame["day_of_week"] = days.dayofweek
|
| 72 |
frame["day_of_year"] = days.dayofyear
|
| 73 |
frame["week_of_year"] = days.isocalendar().week.astype(int)
|
| 74 |
frame["is_weekend"] = days.dayofweek >= 5
|
|
|
|
| 119 |
|
| 120 |
with connect() as con:
|
| 121 |
if rebuild:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
logger.info("Rebuild requested: dropping data-layer tables")
|
| 123 |
for table in (
|
| 124 |
"fact_forecast_accuracy", "fact_demand_hourly",
|
|
|
|
| 137 |
", ".join(stale),
|
| 138 |
)
|
| 139 |
|
|
|
|
|
|
|
|
|
|
| 140 |
logger.info("Building dim_ba")
|
| 141 |
con.register("_dim_ba", _dim_ba_frame())
|
| 142 |
con.execute("CREATE OR REPLACE TABLE dim_ba AS SELECT * FROM _dim_ba")
|
| 143 |
con.unregister("_dim_ba")
|
| 144 |
|
|
|
|
|
|
|
|
|
|
| 145 |
logger.info("Building silver_grid_hourly")
|
| 146 |
con.execute(f"""
|
| 147 |
CREATE OR REPLACE TEMP TABLE _eia_wide AS
|
|
|
|
| 165 |
weather_join = ""
|
| 166 |
weather_select = weather_nulls
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
bounds = con.execute(
|
| 169 |
"SELECT ba_code, min(period_utc) AS lo, max(period_utc) AS hi "
|
| 170 |
"FROM _eia_wide GROUP BY ba_code"
|
|
|
|
| 219 |
ORDER BY s.ba_code, s.period_utc
|
| 220 |
""")
|
| 221 |
|
|
|
|
|
|
|
|
|
|
| 222 |
span = con.execute(
|
| 223 |
"SELECT min(date_local), max(date_local) FROM silver_grid_hourly"
|
| 224 |
).fetchone()
|
|
|
|
| 233 |
)
|
| 234 |
con.unregister("_dim_date")
|
| 235 |
|
|
|
|
|
|
|
|
|
|
| 236 |
logger.info("Building fact_demand_hourly")
|
|
|
|
|
|
|
| 237 |
con.execute("""
|
| 238 |
CREATE OR REPLACE TEMP TABLE _ba_bounds AS
|
| 239 |
SELECT ba_code,
|
|
|
|
| 245 |
GROUP BY ba_code
|
| 246 |
""")
|
| 247 |
|
|
|
|
|
|
|
| 248 |
con.execute("""
|
| 249 |
CREATE OR REPLACE TEMP TABLE _neighbours AS
|
| 250 |
SELECT
|
|
|
|
| 310 |
ORDER BY s.ba_code, s.period_utc
|
| 311 |
""")
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
logger.info("Building fact_forecast_accuracy")
|
| 314 |
con.execute("""
|
| 315 |
CREATE OR REPLACE TABLE fact_forecast_accuracy AS
|
src/gridpulse/warehouse/duck.py
CHANGED
|
@@ -1,10 +1,4 @@
|
|
| 1 |
-
"""DuckDB
|
| 2 |
-
|
| 3 |
-
DuckDB is the warehouse engine because it gives columnar OLAP performance over
|
| 4 |
-
hundreds of millions of rows inside a single embedded file, with no server to run.
|
| 5 |
-
On an 8GB laptop that is the difference between a project that runs and a project
|
| 6 |
-
that swaps. The same SQL ports to Snowflake, BigQuery or Azure Synapse unchanged.
|
| 7 |
-
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
@@ -20,8 +14,6 @@ from gridpulse.config import PATHS
|
|
| 20 |
|
| 21 |
logger = logging.getLogger(__name__)
|
| 22 |
|
| 23 |
-
# Tuned for a constrained laptop: cap DuckDB well below total system RAM so the OS,
|
| 24 |
-
# the Python process and any concurrently running training job all still fit.
|
| 25 |
DEFAULT_MEMORY_LIMIT = "2GB"
|
| 26 |
DEFAULT_THREADS = 4
|
| 27 |
|
|
@@ -41,7 +33,7 @@ def connect(
|
|
| 41 |
try:
|
| 42 |
con.execute(f"SET memory_limit='{memory_limit}'")
|
| 43 |
con.execute(f"SET threads={threads}")
|
| 44 |
-
con.execute("SET preserve_insertion_order=false")
|
| 45 |
yield con
|
| 46 |
finally:
|
| 47 |
con.close()
|
|
|
|
| 1 |
+
"""Opening and closing DuckDB connections, plus a few small query helpers."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
|
|
|
|
|
|
| 17 |
DEFAULT_MEMORY_LIMIT = "2GB"
|
| 18 |
DEFAULT_THREADS = 4
|
| 19 |
|
|
|
|
| 33 |
try:
|
| 34 |
con.execute(f"SET memory_limit='{memory_limit}'")
|
| 35 |
con.execute(f"SET threads={threads}")
|
| 36 |
+
con.execute("SET preserve_insertion_order=false")
|
| 37 |
yield con
|
| 38 |
finally:
|
| 39 |
con.close()
|
src/gridpulse/warehouse/export.py
CHANGED
|
@@ -1,16 +1,7 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
The
|
| 4 |
-
|
| 5 |
-
file containing only what the public site actually reads:
|
| 6 |
-
|
| 7 |
-
* a rolling window of recent hourly demand, weather and forecasts,
|
| 8 |
-
* pre-computed model predictions over the evaluation window,
|
| 9 |
-
* the model leaderboard and data quality scorecard,
|
| 10 |
-
* flagged anomalies.
|
| 11 |
-
|
| 12 |
-
The app therefore starts instantly with no retraining and no warehouse dependency,
|
| 13 |
-
while still calling the live EIA API for anything newer than the last export.
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
|
@@ -26,7 +17,7 @@ from gridpulse.warehouse.duck import connect, row_count
|
|
| 26 |
logger = logging.getLogger(__name__)
|
| 27 |
|
| 28 |
APP_DB_NAME = "gridpulse_app.duckdb"
|
| 29 |
-
EXPORT_WINDOW_DAYS = 400
|
| 30 |
|
| 31 |
|
| 32 |
def export_for_app(window_days: int = EXPORT_WINDOW_DAYS, destination: Path | None = None) -> Path:
|
|
@@ -48,16 +39,11 @@ def export_for_app(window_days: int = EXPORT_WINDOW_DAYS, destination: Path | No
|
|
| 48 |
with connect(target) as con:
|
| 49 |
con.execute(f"ATTACH '{source.as_posix()}' AS wh (READ_ONLY)")
|
| 50 |
|
| 51 |
-
# Dimensions are small; copy wholesale.
|
| 52 |
for table in ("dim_ba", "dim_date"):
|
| 53 |
if _exists_in(con, "wh", table):
|
| 54 |
con.execute(f"CREATE TABLE {table} AS SELECT * FROM wh.{table}")
|
| 55 |
manifest[table] = row_count(con, table)
|
| 56 |
|
| 57 |
-
# The main fact, trimmed to the rolling window. The column list is derived
|
| 58 |
-
# from WEATHER_VARIABLES rather than hand-written: a hand-written list
|
| 59 |
-
# silently drops columns the feature builder needs, and the failure only
|
| 60 |
-
# surfaces at inference time on the deployed site.
|
| 61 |
if _exists_in(con, "wh", "fact_demand_hourly"):
|
| 62 |
columns = ", ".join([
|
| 63 |
"period_utc", "ba_code", "date_local", "hour_local",
|
|
@@ -122,7 +108,6 @@ def _exists_in(con, schema: str, table: str) -> bool:
|
|
| 122 |
).fetchone()
|
| 123 |
if found and found[0]:
|
| 124 |
return True
|
| 125 |
-
# DuckDB reports attached databases via the catalog column in some versions.
|
| 126 |
found = con.execute(
|
| 127 |
"SELECT count(*) FROM information_schema.tables WHERE table_catalog = ? AND table_name = ?",
|
| 128 |
[schema, table],
|
|
|
|
| 1 |
+
"""Writes the small DuckDB file the public app ships with, about 13 MB.
|
| 2 |
|
| 3 |
+
The full warehouse is 129 MB, which is too big for Git and for free hosting, so
|
| 4 |
+
this keeps only a rolling window of what the website actually reads.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
|
|
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
| 19 |
APP_DB_NAME = "gridpulse_app.duckdb"
|
| 20 |
+
EXPORT_WINDOW_DAYS = 400
|
| 21 |
|
| 22 |
|
| 23 |
def export_for_app(window_days: int = EXPORT_WINDOW_DAYS, destination: Path | None = None) -> Path:
|
|
|
|
| 39 |
with connect(target) as con:
|
| 40 |
con.execute(f"ATTACH '{source.as_posix()}' AS wh (READ_ONLY)")
|
| 41 |
|
|
|
|
| 42 |
for table in ("dim_ba", "dim_date"):
|
| 43 |
if _exists_in(con, "wh", table):
|
| 44 |
con.execute(f"CREATE TABLE {table} AS SELECT * FROM wh.{table}")
|
| 45 |
manifest[table] = row_count(con, table)
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
if _exists_in(con, "wh", "fact_demand_hourly"):
|
| 48 |
columns = ", ".join([
|
| 49 |
"period_utc", "ba_code", "date_local", "hour_local",
|
|
|
|
| 108 |
).fetchone()
|
| 109 |
if found and found[0]:
|
| 110 |
return True
|
|
|
|
| 111 |
found = con.execute(
|
| 112 |
"SELECT count(*) FROM information_schema.tables WHERE table_catalog = ? AND table_name = ?",
|
| 113 |
[schema, table],
|
tests/conftest.py
CHANGED
|
@@ -45,7 +45,6 @@ def synthetic_grid() -> pd.DataFrame:
|
|
| 45 |
"period_utc": periods,
|
| 46 |
"ba_code": ba,
|
| 47 |
"demand_mwh": demand.round(1),
|
| 48 |
-
# EIA's forecast: the truth plus a realistic ~2 percent error.
|
| 49 |
"demand_forecast_mwh": (demand * (1 + rng.normal(0, 0.021, len(periods)))).round(1),
|
| 50 |
"net_generation_mwh": (demand * 1.03).round(1),
|
| 51 |
"total_interchange_mwh": rng.normal(0, scale * 0.02, len(periods)).round(1),
|
|
|
|
| 45 |
"period_utc": periods,
|
| 46 |
"ba_code": ba,
|
| 47 |
"demand_mwh": demand.round(1),
|
|
|
|
| 48 |
"demand_forecast_mwh": (demand * (1 + rng.normal(0, 0.021, len(periods)))).round(1),
|
| 49 |
"net_generation_mwh": (demand * 1.03).round(1),
|
| 50 |
"total_interchange_mwh": rng.normal(0, scale * 0.02, len(periods)).round(1),
|
tests/test_features.py
CHANGED
|
@@ -93,7 +93,6 @@ def test_rolling_features_do_not_leak_the_present(raw_frame):
|
|
| 93 |
|
| 94 |
def test_degree_days_split_the_temperature_response(raw_frame):
|
| 95 |
featured = build_features(frame=raw_frame)
|
| 96 |
-
# The two limbs are mutually exclusive: never both positive at once.
|
| 97 |
both_positive = (featured["heating_degrees"] > 0) & (featured["cooling_degrees"] > 0)
|
| 98 |
assert not both_positive.any()
|
| 99 |
assert (featured["heating_degrees"] >= 0).all()
|
|
|
|
| 93 |
|
| 94 |
def test_degree_days_split_the_temperature_response(raw_frame):
|
| 95 |
featured = build_features(frame=raw_frame)
|
|
|
|
| 96 |
both_positive = (featured["heating_degrees"] > 0) & (featured["cooling_degrees"] > 0)
|
| 97 |
assert not both_positive.any()
|
| 98 |
assert (featured["heating_degrees"] >= 0).all()
|
tests/test_metrics.py
CHANGED
|
@@ -17,15 +17,14 @@ def test_perfect_forecast_scores_zero_error():
|
|
| 17 |
|
| 18 |
|
| 19 |
def test_mape_is_computed_correctly():
|
| 20 |
-
# A uniform 10 percent over-forecast must yield exactly 10 percent MAPE.
|
| 21 |
truth = np.array([100.0, 200.0, 400.0])
|
| 22 |
assert metrics.mape(truth, truth * 1.1) == pytest.approx(10.0)
|
| 23 |
|
| 24 |
|
| 25 |
def test_rmse_penalises_large_errors_more_than_mae():
|
| 26 |
truth = np.array([100.0, 100.0, 100.0, 100.0])
|
| 27 |
-
concentrated = np.array([100.0, 100.0, 100.0, 140.0])
|
| 28 |
-
spread = np.array([110.0, 110.0, 110.0, 110.0])
|
| 29 |
assert metrics.mae(truth, concentrated) == pytest.approx(metrics.mae(truth, spread))
|
| 30 |
assert metrics.rmse(truth, concentrated) > metrics.rmse(truth, spread)
|
| 31 |
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def test_mape_is_computed_correctly():
|
|
|
|
| 20 |
truth = np.array([100.0, 200.0, 400.0])
|
| 21 |
assert metrics.mape(truth, truth * 1.1) == pytest.approx(10.0)
|
| 22 |
|
| 23 |
|
| 24 |
def test_rmse_penalises_large_errors_more_than_mae():
|
| 25 |
truth = np.array([100.0, 100.0, 100.0, 100.0])
|
| 26 |
+
concentrated = np.array([100.0, 100.0, 100.0, 140.0])
|
| 27 |
+
spread = np.array([110.0, 110.0, 110.0, 110.0])
|
| 28 |
assert metrics.mae(truth, concentrated) == pytest.approx(metrics.mae(truth, spread))
|
| 29 |
assert metrics.rmse(truth, concentrated) > metrics.rmse(truth, spread)
|
| 30 |
|
tests/test_quality.py
CHANGED
|
@@ -58,7 +58,6 @@ class TestSuiteExecution:
|
|
| 58 |
for r in report.results
|
| 59 |
if r.check.severity is Severity.CRITICAL
|
| 60 |
and not r.passed
|
| 61 |
-
# Freshness is expected to fail: the synthetic series ends in 2024.
|
| 62 |
and r.check.name != "data_freshness"
|
| 63 |
]
|
| 64 |
assert not critical_failures, f"Unexpected critical failures: {critical_failures}"
|
|
|
|
| 58 |
for r in report.results
|
| 59 |
if r.check.severity is Severity.CRITICAL
|
| 60 |
and not r.passed
|
|
|
|
| 61 |
and r.check.name != "data_freshness"
|
| 62 |
]
|
| 63 |
assert not critical_failures, f"Unexpected critical failures: {critical_failures}"
|
tests/test_style.py
CHANGED
|
@@ -8,14 +8,6 @@ import pytest
|
|
| 8 |
|
| 9 |
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
|
| 11 |
-
# Declared by Unicode codepoint rather than as literals, so this file does not
|
| 12 |
-
# itself contain the characters it forbids. (It did, and failed its own check.)
|
| 13 |
-
#
|
| 14 |
-
# U+2014 em dash U+2013 en dash U+2012 figure dash
|
| 15 |
-
# U+2015 horizontal bar U+2010 hyphen U+2011 non-breaking hyphen
|
| 16 |
-
# U+2212 minus sign
|
| 17 |
-
#
|
| 18 |
-
# The project uses the plain ASCII hyphen everywhere instead.
|
| 19 |
FORBIDDEN_DASHES = "".join(
|
| 20 |
chr(code) for code in (0x2014, 0x2013, 0x2012, 0x2015, 0x2010, 0x2011, 0x2212)
|
| 21 |
)
|
|
|
|
| 8 |
|
| 9 |
REPO_ROOT = Path(__file__).resolve().parents[1]
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
FORBIDDEN_DASHES = "".join(
|
| 12 |
chr(code) for code in (0x2014, 0x2013, 0x2012, 0x2015, 0x2010, 0x2011, 0x2212)
|
| 13 |
)
|
tests/test_warehouse.py
CHANGED
|
@@ -111,7 +111,6 @@ def test_forecast_accuracy_table_computes_error(con):
|
|
| 111 |
FROM fact_forecast_accuracy
|
| 112 |
""").df().iloc[0]
|
| 113 |
assert row["n"] > 1000
|
| 114 |
-
# The synthetic benchmark carries roughly 2.1 percent noise by construction.
|
| 115 |
assert 0.5 < row["mape"] < 6.0
|
| 116 |
|
| 117 |
|
|
|
|
| 111 |
FROM fact_forecast_accuracy
|
| 112 |
""").df().iloc[0]
|
| 113 |
assert row["n"] > 1000
|
|
|
|
| 114 |
assert 0.5 < row["mape"] < 6.0
|
| 115 |
|
| 116 |
|