Skip to content

Commit ed58ae9

Browse files
authored
Merge pull request #40 from advanced-computing/Aileen
Enhanced Dashboard for Part 4
2 parents 34798b0 + 4128a7f commit ed58ae9

File tree

7 files changed

+735
-67
lines changed

7 files changed

+735
-67
lines changed

app.py

Lines changed: 252 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,87 @@
11
import streamlit as st
22
import pandas as pd
3+
import plotly.graph_objects as go
34
import plotly.express as px
45

56
from data_utils import (
7+
compute_daily_totals,
68
convert_units,
9+
demand_day_over_day_change,
10+
detect_demand_anomalies,
711
filter_to_timezone,
12+
fuel_mix_on_anomaly_days,
13+
largest_fuel_shifts,
814
parse_period_and_value,
915
top_n_by_total,
1016
)
11-
from eia_api import fetch_all_pages
17+
from eia_api import fetch_daily_fuel
1218
from schemas import validate_fuel_raw, validate_parsed
1319

1420
st.set_page_config(page_title="EIA Fuel Type Demand", layout="wide")
15-
st.title("U.S. Electricity Demand by Fuel Type (Eastern Time)")
21+
st.title("U.S. Electricity Demand by Fuel Type")
22+
st.caption("Data: U.S. Energy Information Administration (EIA) — Eastern Time")
23+
st.markdown("**Team:** Aileen Yang · Aria Kovalovich · Chengpu Deng")
1624

25+
# -------------------
1726
# API Key Retrieval
27+
# -------------------
1828
api_key = st.secrets.get("EIA_API_KEY", None)
29+
# BASE_URL = "https://api.eia.gov/v2/electricity/rto/daily-fuel-type-data/data/"
30+
if not api_key:
31+
st.error("Missing EIA_API_KEY in Streamlit secrets.")
32+
st.stop()
1933

20-
# Predefine time and unit values
21-
start = st.sidebar.text_input("Start date (YYYY-MM-DD)", value="2026-02-09")
22-
end = st.sidebar.text_input("End date (YYYY-MM-DD)", value="2026-02-16")
23-
units = st.sidebar.radio("Units", ["MWh", "GWh"], horizontal=True)
34+
# -------------------
35+
# Sidebar Control
36+
# -------------------
37+
with st.sidebar:
38+
st.header("Settings")
2439

25-
# Adding in filters for user to choose for display
26-
top_n = st.sidebar.slider("Show top N fuel types (by total)", 1, 15, 10)
27-
filter_eastern = st.sidebar.checkbox("Filter to Eastern timezone only", value=True)
40+
start = st.text_input("Start date (YYYY-MM-DD)", value="2026-01-15")
41+
end = st.text_input("End date (YYYY-MM-DD)", value="2026-03-08")
42+
units = st.radio("Units", ["MWh", "GWh"], horizontal=True)
43+
top_n = st.slider("Show top N fuel types (by total)", 1, 15, 10)
44+
filter_eastern = st.checkbox("Filter to Eastern timezone only", value=True)
2845

29-
BASE_URL = "https://api.eia.gov/v2/electricity/rto/daily-fuel-type-data/data/"
46+
st.divider()
47+
st.subheader("Anomaly Detection")
48+
z_threshold = st.slider(
49+
"Z-score threshold for anomaly flagging",
50+
min_value=0.5,
51+
max_value=3.0,
52+
value=1.5,
53+
step=0.1,
54+
help="Days where total demand deviates more than this many standard deviations are flagged.",
55+
)
56+
anomaly_focus = st.radio(
57+
"Fuel-mix shift analysis: compare",
58+
["high_demand", "low_demand"],
59+
format_func=lambda x: (
60+
"High-demand days vs normal"
61+
if x == "high_demand"
62+
else "Low-demand days vs normal"
63+
),
64+
)
65+
chart_type = st.radio("Chart type", ["Line", "Stacked Area"], index=0)
3066

3167

68+
# -------------------
69+
# Data Loading
70+
# -------------------
3271
@st.cache_data(show_spinner=False)
3372
def load_fuel_data(api_key: str, start: str, end: str) -> pd.DataFrame:
34-
params = {
35-
"api_key": api_key,
36-
"frequency": "daily",
37-
"data[0]": "value",
38-
"start": start,
39-
"end": end,
40-
"sort[0][column]": "period",
41-
"sort[0][direction]": "asc",
42-
"offset": 0,
43-
"length": 5000,
44-
}
45-
rows = fetch_all_pages(BASE_URL, params)
73+
rows = fetch_daily_fuel(api_key, start, end)
4674
return pd.json_normalize(rows)
4775

4876

49-
with st.spinner("Loading data..."):
50-
df = load_fuel_data(api_key, start, end)
77+
with st.spinner("Loading data from EIA..."):
78+
df_raw = load_fuel_data(api_key, start, end)
5179

52-
if df.empty:
53-
st.warning("No data returned. Check dates/API key.")
80+
if df_raw.empty:
81+
st.warning("No data returned. Check API key.")
5482
st.stop()
5583

56-
df, raw_warnings = validate_fuel_raw(df)
84+
df, raw_warnings = validate_fuel_raw(df_raw)
5785
for warning in raw_warnings:
5886
st.warning(warning)
5987

@@ -77,23 +105,211 @@ def load_fuel_data(api_key: str, start: str, end: str) -> pd.DataFrame:
77105

78106
df, ycol, ylabel = convert_units(df, units)
79107

108+
# -------------------
80109
# Aggregation by date and fuel type
110+
# -------------------
81111
agg = (
82112
df.groupby(["period", "type-name"], as_index=False)[ycol]
83113
.sum()
84114
.rename(columns={ycol: "Demand"})
85-
) # type: ignore
115+
)
86116

87117
# Keep top N fuel types by total
88118
agg = top_n_by_total(agg, "type-name", "Demand", top_n=top_n)
89119

90-
# Plot Graph
91-
fig = px.line(
92-
agg.sort_values("period"),
93-
x="period",
94-
y="Demand",
95-
color="type-name",
96-
title=f"Electricity demand by fuel type ({start} to {end})",
97-
labels={"period": "Date", "Demand": ylabel, "type-name": "Fuel type"},
120+
# -------------------
121+
# Plot Graph (Main Demand)
122+
# -------------------
123+
st.subheader("Electricity Demand by Fuel Type")
124+
agg_sorted = agg.sort_values("period")
125+
126+
if chart_type == "Stacked Area":
127+
fig = px.area(
128+
agg_sorted,
129+
x="period",
130+
y="Demand",
131+
color="type-name",
132+
title=f"Electricity demand by fuel type — stacked area ({start} to {end})",
133+
labels={"period": "Date", "Demand": ylabel, "type-name": "Fuel type"},
134+
)
135+
fig.update_traces(mode="lines")
136+
else:
137+
fig = px.line(
138+
agg_sorted,
139+
x="period",
140+
y="Demand",
141+
color="type-name",
142+
title=f"Electricity demand by fuel type ({start} to {end})",
143+
labels={"period": "Date", "Demand": ylabel, "type-name": "Fuel type"},
144+
)
145+
146+
fig.update_layout(
147+
legend=dict(orientation="v", yanchor="top", y=1, xanchor="left", x=1.01),
148+
hovermode="x unified",
98149
)
99150
st.plotly_chart(fig, use_container_width=True)
151+
152+
# -------------------
153+
# Plot Graph (Grid Stress & Demand Anomaly Detection)
154+
# -------------------
155+
st.subheader("Grid Stress & Demand Anomaly Detection")
156+
st.markdown(
157+
f"Days where total demand deviates more than **{z_threshold}σ** from the mean are flagged."
158+
)
159+
160+
daily = compute_daily_totals(df, value_col=ycol)
161+
daily = demand_day_over_day_change(daily)
162+
daily = detect_demand_anomalies(daily, z_threshold=z_threshold)
163+
164+
# Plot total demand with anomaly markers
165+
fig2 = go.Figure()
166+
fig2.add_trace(
167+
go.Scatter(
168+
x=daily["period"],
169+
y=daily["total_demand"],
170+
mode="lines",
171+
name="Total demand",
172+
line=dict(color="#4C78A8", width=2),
173+
)
174+
)
175+
176+
high_days = daily[daily["anomaly_type"] == "high"]
177+
low_days = daily[daily["anomaly_type"] == "low"]
178+
179+
if not high_days.empty:
180+
fig2.add_trace(
181+
go.Scatter(
182+
x=high_days["period"],
183+
y=high_days["total_demand"],
184+
mode="markers",
185+
name="High-demand anomaly",
186+
marker=dict(color="red", size=10, symbol="triangle-up"),
187+
hovertemplate="<b>HIGH</b><br>%{x}<br>Demand: %{y:,.0f}<br>Z: %{customdata:.2f}",
188+
customdata=high_days["demand_zscore"],
189+
)
190+
)
191+
192+
if not low_days.empty:
193+
fig2.add_trace(
194+
go.Scatter(
195+
x=low_days["period"],
196+
y=low_days["total_demand"],
197+
mode="markers",
198+
name="Low-demand anomaly",
199+
marker=dict(color="blue", size=10, symbol="triangle-down"),
200+
hovertemplate="<b>LOW</b><br>%{x}<br>Demand: %{y:,.0f}<br>Z: %{customdata:.2f}",
201+
customdata=low_days["demand_zscore"],
202+
)
203+
)
204+
205+
fig2.update_layout(
206+
title="Total daily demand with anomaly markers",
207+
xaxis_title="Date",
208+
yaxis_title=ylabel,
209+
hovermode="x unified",
210+
)
211+
st.plotly_chart(fig2, use_container_width=True)
212+
213+
# Day-over-day change chart
214+
fig3 = px.bar(
215+
daily,
216+
x="period",
217+
y="demand_pct_change",
218+
title="Day-over-day % change in total demand",
219+
labels={"period": "Date", "demand_pct_change": "Change (%)"},
220+
color="demand_pct_change",
221+
color_continuous_scale=["blue", "lightgrey", "red"],
222+
color_continuous_midpoint=0,
223+
)
224+
fig3.update_layout(coloraxis_showscale=False)
225+
st.plotly_chart(fig3, use_container_width=True)
226+
227+
# Summary table
228+
n_high = (daily["anomaly_type"] == "high").sum()
229+
n_low = (daily["anomaly_type"] == "low").sum()
230+
col1, col2, col3 = st.columns(3)
231+
col1.metric("Total days analyzed", len(daily))
232+
col2.metric("High-demand anomaly days", n_high)
233+
col3.metric("Low-demand anomaly days", n_low)
234+
235+
if not daily[daily["anomaly_type"].notna()].empty:
236+
with st.expander("View anomaly day details"):
237+
anomaly_table = daily[daily["anomaly_type"].notna()][
238+
[
239+
"period",
240+
"total_demand",
241+
"demand_zscore",
242+
"demand_pct_change",
243+
"anomaly_type",
244+
]
245+
].copy()
246+
anomaly_table["period"] = anomaly_table["period"].dt.strftime("%Y-%m-%d")
247+
anomaly_table.columns = ["Date", ylabel, "Z-Score", "Day-over-Day %", "Type"]
248+
st.dataframe(anomaly_table.reset_index(drop=True), use_container_width=True)
249+
250+
# -------------------
251+
# Plot Graph (Fuel Mix Shift on Anomaly Days)
252+
# -------------------
253+
st.subheader("Fuel Mix Shifts on Anomaly Days")
254+
st.markdown(
255+
"How does the **fuel mix (% share)** change on high- or low-demand days vs normal days?"
256+
)
257+
258+
# Re-use df with original value col for shares
259+
mix_comparison = fuel_mix_on_anomaly_days(
260+
df,
261+
daily,
262+
fuel_col="type-name",
263+
value_col=ycol,
264+
anomaly_type="high" if anomaly_focus == "high_demand" else "low",
265+
)
266+
267+
if mix_comparison.empty:
268+
st.info(
269+
"No anomaly days found with current threshold. Try lowering the z-score slider."
270+
)
271+
else:
272+
shifts = largest_fuel_shifts(
273+
mix_comparison,
274+
fuel_col="type-name",
275+
anomaly_label="high_demand" if anomaly_focus == "high_demand" else "low_demand",
276+
)
277+
278+
label = "High" if anomaly_focus == "high_demand" else "Low"
279+
fig4 = px.bar(
280+
mix_comparison,
281+
x="type-name",
282+
y="avg_share_pct",
283+
color="day_type",
284+
barmode="group",
285+
title=f"Avg fuel share (%) — {label}-demand days vs normal",
286+
labels={
287+
"type-name": "Fuel type",
288+
"avg_share_pct": "Avg share (%)",
289+
"day_type": "Day type",
290+
},
291+
color_discrete_map={
292+
"high_demand": "#d62728",
293+
"low_demand": "#1f77b4",
294+
"normal": "#aec7e8",
295+
},
296+
)
297+
fig4.update_layout(xaxis_tickangle=-35)
298+
st.plotly_chart(fig4, use_container_width=True)
299+
300+
if not shifts.empty and "shift_pct" in shifts.columns:
301+
fig5 = px.bar(
302+
shifts,
303+
x="type-name",
304+
y="shift_pct",
305+
title=f"Fuel mix shift: {label}-demand days minus normal (percentage points)",
306+
labels={"type-name": "Fuel type", "shift_pct": "Shift (pp)"},
307+
color="shift_pct",
308+
color_continuous_scale=["blue", "lightgrey", "red"],
309+
color_continuous_midpoint=0,
310+
)
311+
fig5.update_layout(coloraxis_showscale=False, xaxis_tickangle=-35)
312+
st.plotly_chart(fig5, use_container_width=True)
313+
314+
with st.expander("View shift data table"):
315+
st.dataframe(shifts.reset_index(drop=True), use_container_width=True)

0 commit comments

Comments
 (0)