1111start_time = time .time ()
1212
1313st .set_page_config (page_title = "Weekly U.S. Petroleum Supply" , layout = "wide" )
14- st .title ("U.S. Petroleum & WTI Weekly Monitor" )
14+
15+ # =========================
16+ # Sidebar title
17+ # =========================
18+ st .sidebar .markdown (
19+ """
20+ <h1 style="font-size: 1.5rem; line-height: 1.2; margin-bottom: 0.2rem;">
21+ U.S. Petroleum & WTI Weekly Monitor
22+ </h1>
23+ """ ,
24+ unsafe_allow_html = True ,
25+ )
26+ st .sidebar .caption ("Source: EIA" )
27+ st .sidebar .divider ()
28+
29+ # =========================
30+ # Main page header
31+ # =========================
32+ st .title ("Weekly U.S. Petroleum Supply" )
1533st .subheader ("Team Members: Irina, Indra" )
1634st .caption ("Source: U.S. Energy Information Administration (EIA)" )
1735
7997PROJECT_ID = "sipa-adv-c-giggling-wombat"
8098TOTAL_SUPPLY_TABLE_ID = f"{ PROJECT_ID } .petroleum_supply.weekly_supply"
8199PRODUCT_SUPPLY_TABLE_ID = f"{ PROJECT_ID } .petroleum_supply.weekly_supply_by_product"
100+ WTI_TABLE_ID = f"{ PROJECT_ID } .petroleum_supply.weekly_wti"
101+
82102DEFAULT_PRODUCT_COUNT = 3
103+ MIN_CORRELATION_POINTS = 12
104+ TOP_ANALYSIS_COUNT = 10
105+ TWO_COLUMN_LAYOUT = 2
106+ THREE_COLUMN_LAYOUT = 3
83107
84108
85109@st .cache_resource
@@ -101,7 +125,7 @@ def load_supply_data() -> pd.DataFrame:
101125 FROM `{ TOTAL_SUPPLY_TABLE_ID } `
102126 ORDER BY week
103127 """
104- df = client .query (query ).to_dataframe ()
128+ df = client .query (query ).to_dataframe (create_bqstorage_client = False )
105129 df ["week" ] = pd .to_datetime (df ["week" ])
106130 df ["total_supply" ] = pd .to_numeric (df ["total_supply" ], errors = "coerce" )
107131 df = df .dropna (subset = ["week" , "total_supply" ])
@@ -116,13 +140,84 @@ def load_supply_product_data() -> pd.DataFrame:
116140 FROM `{ PRODUCT_SUPPLY_TABLE_ID } `
117141 ORDER BY week
118142 """
119- df = client .query (query ).to_dataframe ()
143+ df = client .query (query ).to_dataframe (create_bqstorage_client = False )
120144 df ["week" ] = pd .to_datetime (df ["week" ])
121145 df ["product_supplied" ] = pd .to_numeric (df ["product_supplied" ], errors = "coerce" )
122146 df = df .dropna (subset = ["week" , "product" , "product_name" , "product_supplied" ])
123147 return df
124148
125149
150+ @st .cache_data (ttl = 60 * 60 )
151+ def load_wti_data () -> pd .DataFrame :
152+ client = get_bq_client ()
153+ query = f"""
154+ SELECT week, wti_price
155+ FROM `{ WTI_TABLE_ID } `
156+ ORDER BY week
157+ """
158+ df = client .query (query ).to_dataframe (create_bqstorage_client = False )
159+ df ["week" ] = pd .to_datetime (df ["week" ])
160+ df ["wti_price" ] = pd .to_numeric (df ["wti_price" ], errors = "coerce" )
161+ df = df .dropna (subset = ["week" , "wti_price" ])
162+ return df
163+
164+
165+ def compute_product_price_sensitivity (
166+ product_df : pd .DataFrame ,
167+ wti_df : pd .DataFrame ,
168+ ) -> pd .DataFrame :
169+ merged = product_df .merge (wti_df , on = "week" , how = "inner" )
170+
171+ if merged .empty :
172+ return pd .DataFrame ()
173+
174+ results = []
175+
176+ grouped = merged .groupby (["product" , "product_name" ], dropna = False )
177+
178+ for (product_code , product_name ), group in grouped :
179+ group = group .dropna (subset = ["product_supplied" , "wti_price" ]).copy ()
180+
181+ if len (group ) < MIN_CORRELATION_POINTS :
182+ continue
183+
184+ if group ["product_supplied" ].nunique () < TWO_COLUMN_LAYOUT :
185+ continue
186+
187+ if group ["wti_price" ].nunique () < TWO_COLUMN_LAYOUT :
188+ continue
189+
190+ correlation = group ["product_supplied" ].corr (group ["wti_price" ])
191+
192+ if pd .isna (correlation ):
193+ continue
194+
195+ direction = "Positive" if correlation >= 0 else "Negative"
196+
197+ results .append (
198+ {
199+ "product" : product_code ,
200+ "product_name" : product_name ,
201+ "correlation_with_wti" : correlation ,
202+ "abs_correlation" : abs (correlation ),
203+ "direction" : direction ,
204+ "weeks_used" : len (group ),
205+ }
206+ )
207+
208+ if not results :
209+ return pd .DataFrame ()
210+
211+ result_df = (
212+ pd .DataFrame (results ).sort_values ("abs_correlation" , ascending = False ).reset_index (drop = True )
213+ )
214+
215+ return result_df
216+
217+
218+ # =========================
219+ # Load data
220+ # =========================
126221try :
127222 weekly_total = load_supply_data ()
128223except Exception as e :
@@ -133,8 +228,28 @@ def load_supply_product_data() -> pd.DataFrame:
133228 st .error ("No supply data found in BigQuery." )
134229 st .stop ()
135230
231+ try :
232+ weekly_by_product = load_supply_product_data ()
233+ except Exception as e :
234+ st .error (f"Failed to load product-level supply data from BigQuery: { e } " )
235+ st .stop ()
236+
237+ try :
238+ weekly_wti = load_wti_data ()
239+ except Exception as e :
240+ st .error (f"Failed to load WTI data from BigQuery: { e } " )
241+ st .stop ()
242+
243+ if weekly_by_product .empty :
244+ st .error ("No product-level supply data found in BigQuery." )
245+ st .stop ()
246+
247+ if weekly_wti .empty :
248+ st .error ("No WTI data found in BigQuery." )
249+ st .stop ()
250+
136251# =========================
137- # Interactive Filters
252+ # Sidebar Filters
138253# =========================
139254st .sidebar .header ("Filters" )
140255
@@ -170,17 +285,16 @@ def load_supply_product_data() -> pd.DataFrame:
170285 st .warning ("No data available for the selected date range." )
171286 st .stop ()
172287
173- try :
174- weekly_by_product = load_supply_product_data ()
175- except Exception as e :
176- st .error (f"Failed to load product-level supply data from BigQuery: { e } " )
177- st .stop ()
178-
179288filtered_product = weekly_by_product [
180289 (weekly_by_product ["week" ] >= pd .to_datetime (start_week ))
181290 & (weekly_by_product ["week" ] <= pd .to_datetime (end_week ))
182291].copy ()
183292
293+ filtered_wti = weekly_wti [
294+ (weekly_wti ["week" ] >= pd .to_datetime (start_week ))
295+ & (weekly_wti ["week" ] <= pd .to_datetime (end_week ))
296+ ].copy ()
297+
184298product_options = sorted (filtered_product ["product_name" ].dropna ().unique ().tolist ())
185299
186300selected_products = st .sidebar .multiselect (
@@ -203,57 +317,121 @@ def load_supply_product_data() -> pd.DataFrame:
203317except Exception :
204318 latest_total = None
205319
206- c1 , c2 = st .columns (2 )
320+ c1 , c2 = st .columns (TWO_COLUMN_LAYOUT )
207321c1 .metric ("Weeks in selected range" , f"{ filtered_total .shape [0 ]:,} " )
208322c2 .metric (
209323 "Latest total (sum of products)" ,
210324 f"{ latest_total :,.0f} " if latest_total is not None else "—" ,
211325)
212326
213- st .divider ()
214- st .subheader ("Total Product Supplied (Weekly, All Products Summed)" )
215-
216- fig , ax = plt .subplots (figsize = (8 , 4 ))
217- ax .plot (filtered_total ["week" ], filtered_total ["total_supply" ])
218- ax .set_xlabel ("Week" )
219- ax .set_ylabel ("Total Product Supplied" )
220- st .pyplot (fig )
221-
222- with st .expander ("Show total supply data table" ):
223- st .dataframe (
224- filtered_total .sort_values ("week" , ascending = False ),
225- use_container_width = True ,
226- )
227-
228327st .caption (
229328 "Note: 'Product supplied' is often used as a proxy for consumption. "
230- "This visualization is descriptive (not causal) ."
329+ "This dashboard is descriptive rather than causal."
231330)
232331
233332st .divider ()
234- st .subheader ("Product-Level Weekly Supply" )
235333
236- if not selected_products :
237- st .warning ("Please select at least one product from the sidebar." )
334+ # =========================
335+ # Two side-by-side charts
336+ # =========================
337+ left_col , right_col = st .columns (TWO_COLUMN_LAYOUT )
338+
339+ with left_col :
340+ st .subheader ("Total Product Supplied" )
341+
342+ fig , ax = plt .subplots (figsize = (7 , 4 ))
343+ ax .plot (filtered_total ["week" ], filtered_total ["total_supply" ])
344+ ax .set_xlabel ("Week" )
345+ ax .set_ylabel ("Total Product Supplied" )
346+ st .pyplot (fig )
347+
348+ with st .expander ("Show total supply data table" ):
349+ total_display = filtered_total .sort_values ("week" , ascending = False ).copy ()
350+ total_display ["week" ] = total_display ["week" ].dt .strftime ("%Y-%m-%d" )
351+ st .dataframe (total_display , width = "stretch" )
352+
353+ with right_col :
354+ st .subheader ("Product-Level Weekly Supply" )
355+
356+ if not selected_products :
357+ st .warning ("Please select at least one product from the sidebar." )
358+ else :
359+ product_plot_df = filtered_product [
360+ filtered_product ["product_name" ].isin (selected_products )
361+ ].copy ()
362+
363+ fig2 , ax2 = plt .subplots (figsize = (7 , 4 ))
364+ for product_name in selected_products :
365+ temp = product_plot_df [product_plot_df ["product_name" ] == product_name ]
366+ ax2 .plot (temp ["week" ], temp ["product_supplied" ], label = product_name )
367+
368+ ax2 .set_xlabel ("Week" )
369+ ax2 .set_ylabel ("Product Supplied" )
370+ ax2 .legend ()
371+ st .pyplot (fig2 )
372+
373+ with st .expander ("Show product-level data table" ):
374+ product_display = product_plot_df .sort_values (
375+ ["product_name" , "week" ], ascending = [True , False ]
376+ ).copy ()
377+ product_display ["week" ] = product_display ["week" ].dt .strftime ("%Y-%m-%d" )
378+ st .dataframe (product_display , width = "stretch" )
379+
380+ st .divider ()
381+
382+ # =========================
383+ # Product sensitivity section
384+ # =========================
385+ st .subheader ("Product Sensitivity to WTI Price" )
386+
387+ sensitivity_df = compute_product_price_sensitivity (filtered_product , filtered_wti )
388+
389+ if sensitivity_df .empty :
390+ st .warning ("Not enough overlapping weekly data to evaluate product sensitivity to WTI price." )
238391else :
239- product_plot_df = filtered_product [
240- filtered_product ["product_name" ].isin (selected_products )
241- ].copy ()
392+ top_product = sensitivity_df .iloc [0 ]
242393
243- fig2 , ax2 = plt .subplots (figsize = (8 , 4 ))
244- for product_name in selected_products :
245- temp = product_plot_df [product_plot_df ["product_name" ] == product_name ]
246- ax2 .plot (temp ["week" ], temp ["product_supplied" ], label = product_name )
394+ m1 , m2 , m3 = st .columns (THREE_COLUMN_LAYOUT )
395+ m1 .metric ("Most price-sensitive product" , top_product ["product_name" ])
396+ m2 .metric (
397+ "Correlation with WTI" ,
398+ f"{ top_product ['correlation_with_wti' ]:.2f} " ,
399+ )
400+ m3 .metric ("Weeks used" , f"{ int (top_product ['weeks_used' ])} " )
247401
248- ax2 .set_xlabel ("Week" )
249- ax2 .set_ylabel ("Product Supplied" )
250- ax2 .legend ()
251- st .pyplot (fig2 )
402+ st .caption (
403+ "Products are ranked by the absolute correlation between weekly product "
404+ "supplied and weekly WTI price over the selected date range. This is a "
405+ "descriptive measure, not a causal estimate."
406+ )
407+
408+ chart_df = sensitivity_df .head (TOP_ANALYSIS_COUNT ).sort_values (
409+ "abs_correlation" , ascending = True
410+ )
411+
412+ fig3 , ax3 = plt .subplots (figsize = (6 , 3.5 ))
413+ ax3 .barh (
414+ chart_df ["product_name" ],
415+ chart_df ["abs_correlation" ],
416+ color = "darkorange" ,
417+ )
418+ ax3 .set_xlabel ("Absolute correlation with WTI price" )
419+ ax3 .set_ylabel ("Product" )
420+ st .pyplot (fig3 )
252421
253- with st .expander ("Show product-level data table" ):
422+ with st .expander ("Show product sensitivity table" ):
254423 st .dataframe (
255- product_plot_df .sort_values (["product_name" , "week" ], ascending = [True , False ]),
256- use_container_width = True ,
424+ sensitivity_df [
425+ [
426+ "product" ,
427+ "product_name" ,
428+ "correlation_with_wti" ,
429+ "abs_correlation" ,
430+ "direction" ,
431+ "weeks_used" ,
432+ ]
433+ ],
434+ width = "stretch" ,
257435 )
258436
259437elapsed = time .time () - start_time
0 commit comments