11import streamlit as st
22import pandas as pd
3+ import plotly .graph_objects as go
34import plotly .express as px
45
56from 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
1218from schemas import validate_fuel_raw , validate_parsed
1319
1420st .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+ # -------------------
1828api_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 )
3372def 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 )
5785for 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
78106df , ycol , ylabel = convert_units (df , units )
79107
108+ # -------------------
80109# Aggregation by date and fuel type
110+ # -------------------
81111agg = (
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
88118agg = 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)
99150st .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