adwitiyashukla commited on
Commit
deab3f9
·
verified ·
1 Parent(s): 18a63f1

Sync from GitHub 1db426b

Browse files
app.py CHANGED
@@ -1,10 +1,5 @@
1
- """GridPulse public web application.
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 written as Dagster assets.
2
 
3
- Dagster is built around *assets*, meaning the tables and files that need to exist,
4
- rather than around *tasks* that need to run. That fits this project well, because
5
- every node below really is a table or a file. So the diagram in the Dagster UI is
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
- """Letting people ask questions in plain English instead of writing SQL.
2
-
3
- You can ask "which region had the worst forecast error last month?" and get a
4
- chart back rather than a SQL editor. The part I actually had to think about is not
5
- calling the LLM, which is about four lines of code. It is not trusting what comes
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
- """GridPulse REST API.
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
- """GridPulse command line interface.
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: # quality warnings should not halt the run
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
- """Central configuration and the balancing-authority registry.
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: # optional: absent on minimal app deployments
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 US balancing authority and the weather station proxy for its load centre.
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", # actual hourly demand
95
- "DF": "demand_forecast_mwh", # EIA's OWN published day-ahead forecast -> our benchmark
96
- "NG": "net_generation_mwh", # net generation
97
- "TI": "total_interchange_mwh", # net exports to neighbours
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
- """Building the features the models learn from.
2
-
3
- I picked these based on what actually makes electricity demand go up and down,
4
- rather than throwing every available column at the model:
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 relative to the BA's own median.
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
- """Construct the full modelling matrix.
184
-
185
- Parameters
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 strictly by time, never randomly.
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
- """Downloading the hourly grid data from the EIA API.
2
 
3
- For each region I pull four hourly series from the ``region-data`` endpoint:
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 async HTTP plumbing: bounded concurrency, retries and honest backoff.
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
- """Weather extraction from Open-Meteo (no API key required).
2
 
3
- Weather is the dominant exogenous driver of electricity demand: air conditioning
4
- load in summer and resistive heating in winter produce the characteristic U-shaped
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 # ERA5 publication delay, with a day of safety margin
39
- FORECAST_PAST_DAYS = 92 # maximum look-back the forecast endpoint allows
40
- FORECAST_AHEAD_DAYS = 16 # maximum look-ahead
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
- """Finding unusual hours: broken meters, sudden demand changes and strange days.
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 # robust z beyond which an hour is suspicious
44
- CONTAMINATION = 0.01 # expected anomaly rate for Isolation Forest
45
- AE_PERCENTILE = 99.0 # reconstruction-error percentile defining "unusual shape"
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
- """Reshape into one normalised 24-value vector per (BA, local date).
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(), # bottleneck
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
- """Naive and classical statistical baselines.
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
- """The deep learning models, written in PyTorch and built to run on a CPU.
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] # (B, hidden)
183
- known = self.future_proj(future.flatten(start_dim=1)) # (B, hidden)
184
- return self.head(torch.cat([context, known], dim=1)) # (B, horizon)
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) # attention-pooled summary
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
- """Per-BA target scaling.
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
- """Robust per-BA centre and scale (median / IQR).
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 # (T, n_past) scaled
317
- future: np.ndarray # (T, n_future) scaled
318
- target_scaled: np.ndarray # (T,) scaled
319
- target_raw: np.ndarray # (T,) MWh
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
- """Assign every window to a split by the timestamp of its first forecast hour.
350
 
351
- Windows are cut over the *full* contiguous series and only then partitioned, so
352
- no usable window is lost at a split boundary. A window is assigned to test only
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
- """Score every test window and flatten to one row per (BA, timestamp).
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 demand forecasting, with P10/P50/P90 prediction bands.
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, so they can share one model.
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
- """Work out the middle and the spread of demand for each region.
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
- """Live inference: produce a genuine forward-looking 24-hour forecast.
2
-
3
- Backtesting on stored history is easy. Forecasting *tomorrow* is the part that
4
- usually breaks, because it needs three things stitched together at request time:
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 # comfortably covers the 336-hour deepest lag plus slack
34
 
35
 
36
  @dataclass
37
  class Forecast:
38
  ba_code: str
39
  generated_at_utc: datetime
40
- mode: str # "live" or "replay"
41
- frame: pd.DataFrame # period_utc, forecast_mwh, p10, p90, [actual_mwh]
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
- """The metrics used to score the forecasts, chosen to match how utilities score them.
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
- """Runs the training: builds the features, fits every model, scores them the same way.
2
-
3
- Comparing them fairly is the whole point. Six forecasters are tested on exactly the
4
- same set of held-out hours, and the one they are compared against is not a baseline
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
- """The data quality checks.
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" # fails the pipeline
32
- WARNING = "warning" # logged and tracked, does not block
33
- INFO = "info" # observability only
34
 
35
 
36
  class Dimension(str, Enum):
@@ -44,18 +32,15 @@ class Dimension(str, Enum):
44
 
45
  @dataclass(frozen=True)
46
  class Check:
47
- """A single quality assertion.
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 # tolerated failure fraction
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, # BAs genuinely miss the occasional hour
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
- """Execute every check and optionally persist the results.
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
- """Turning raw downloads into the warehouse: bronze, then silver, then gold.
2
 
3
- Each of the three layers has one job:
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
- # I find spikes by comparing each point against a rolling median centred on it,
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 # Monday = 0
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 connection management.
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") # lower peak memory on big sorts
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
- """Build the slim deployment artifact the public app ships with.
2
 
3
- The development warehouse holds years of hourly history across every table. Git and
4
- free-tier hosting do not want that. This module distils it into a compact DuckDB
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 # a little over a year, so the app can show YoY comparisons
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]) # one big miss
28
- spread = np.array([110.0, 110.0, 110.0, 110.0]) # four small misses
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