mattritchey commited on
Commit
314f46d
·
verified ·
1 Parent(s): 7ea1d19

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py.py +421 -0
  2. requirements.txt +4 -3
app.py.py ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date, timedelta
2
+
3
+ import altair as alt
4
+ import pandas as pd
5
+ import streamlit as st
6
+ from geopy.extra.rate_limiter import RateLimiter
7
+ from geopy.geocoders import Nominatim
8
+
9
+ st.set_page_config(
10
+ page_title="Weather dashboard",
11
+ page_icon=":material/thermostat:",
12
+ layout="wide",
13
+ )
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Constants
17
+ # ---------------------------------------------------------------------------
18
+ TIME_RANGES = ["1M", "3M", "6M", "1Y", "YTD", "All"]
19
+ VARIABLES = ["Temperature", "Wind speed", "Wind gusts", "Precipitation"]
20
+ VAR_COLS = {
21
+ "Temperature": "temperature_2m",
22
+ "Wind speed": "windspeed_10m",
23
+ "Wind gusts": "windgusts_10m",
24
+ "Precipitation": "precipitation",
25
+ }
26
+ VAR_UNITS = {
27
+ "Temperature": "F",
28
+ "Wind speed": "mph",
29
+ "Wind gusts": "mph",
30
+ "Precipitation": "in",
31
+ }
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Data helpers
36
+ # ---------------------------------------------------------------------------
37
+ @st.cache_data(show_spinner=False)
38
+ def geocode(address: str) -> tuple[float, float]:
39
+ """Return (lat, lon) for *address*, trying Census first then Nominatim."""
40
+ try:
41
+ address2 = address.replace(" ", "+").replace(",", "%2C")
42
+ url = (
43
+ "https://geocoding.geo.census.gov/geocoder/locations/onelineaddress"
44
+ f"?address={address2}&benchmark=2020&format=json"
45
+ )
46
+ df = pd.read_json(url)
47
+ coords = df.iloc[:1, 0][0][0]["coordinates"]
48
+ return coords["y"], coords["x"]
49
+ except Exception:
50
+ geolocator = Nominatim(user_agent="WeatherDashboard")
51
+ geocode_fn = RateLimiter(geolocator.geocode, min_delay_seconds=1)
52
+ location = geocode_fn(address)
53
+ if location is None:
54
+ raise ValueError(f"Could not geocode: {address}")
55
+ return location.latitude, location.longitude
56
+
57
+
58
+ @st.cache_data(show_spinner=False, ttl=900)
59
+ def get_weather_data(
60
+ lat: float, lon: float, start_date: str, end_date: str
61
+ ) -> pd.DataFrame:
62
+ """Fetch hourly weather from Open-Meteo archive API."""
63
+ url = (
64
+ f"https://archive-api.open-meteo.com/v1/archive"
65
+ f"?latitude={lat}&longitude={lon}"
66
+ f"&start_date={start_date}&end_date={end_date}"
67
+ f"&hourly=temperature_2m,precipitation,windspeed_10m,windgusts_10m"
68
+ f"&models=best_match"
69
+ f"&temperature_unit=fahrenheit&windspeed_unit=mph&precipitation_unit=inch"
70
+ )
71
+ raw = pd.read_json(url).reset_index()
72
+ data = pd.DataFrame({c["index"]: c["hourly"] for _, c in raw.iterrows()})
73
+ data["time"] = pd.to_datetime(data["time"])
74
+ data = data.dropna(subset=["temperature_2m"])
75
+ return data
76
+
77
+
78
+ def aggregate_daily(df: pd.DataFrame) -> pd.DataFrame:
79
+ """Compute daily aggregates from hourly data."""
80
+ df = df.copy()
81
+ df["date"] = df["time"].dt.date
82
+ agg = df.groupby("date").agg(
83
+ temperature_2m_min=("temperature_2m", "min"),
84
+ temperature_2m_mean=("temperature_2m", "mean"),
85
+ temperature_2m_max=("temperature_2m", "max"),
86
+ precipitation_sum=("precipitation", "sum"),
87
+ windspeed_10m_min=("windspeed_10m", "min"),
88
+ windspeed_10m_mean=("windspeed_10m", "mean"),
89
+ windspeed_10m_max=("windspeed_10m", "max"),
90
+ windgusts_10m_min=("windgusts_10m", "min"),
91
+ windgusts_10m_mean=("windgusts_10m", "mean"),
92
+ windgusts_10m_max=("windgusts_10m", "max"),
93
+ )
94
+ agg.index = pd.to_datetime(agg.index)
95
+ agg.index.name = "date"
96
+ return agg
97
+
98
+
99
+ def filter_by_time_range(
100
+ df: pd.DataFrame, x_col: str, time_range: str
101
+ ) -> pd.DataFrame:
102
+ """Filter dataframe by a preset time range."""
103
+ if time_range == "All" or df.empty:
104
+ return df
105
+ df = df.copy()
106
+ df[x_col] = pd.to_datetime(df[x_col])
107
+ max_date = df[x_col].max()
108
+ if time_range == "1M":
109
+ min_date = max_date - timedelta(days=30)
110
+ elif time_range == "3M":
111
+ min_date = max_date - timedelta(days=90)
112
+ elif time_range == "6M":
113
+ min_date = max_date - timedelta(days=180)
114
+ elif time_range == "1Y":
115
+ min_date = max_date - timedelta(days=365)
116
+ elif time_range == "YTD":
117
+ min_date = pd.Timestamp(date(max_date.year, 1, 1))
118
+ else:
119
+ return df
120
+ return df[df[x_col] >= min_date]
121
+
122
+
123
+ @st.cache_data
124
+ def to_csv(df: pd.DataFrame) -> bytes:
125
+ return df.to_csv(index=True).encode("utf-8")
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Sidebar
130
+ # ---------------------------------------------------------------------------
131
+ with st.sidebar:
132
+ st.header("Settings")
133
+
134
+ address = st.text_input(
135
+ "Address",
136
+ value="1000 Main St, Cincinnati, OH 45202",
137
+ placeholder="Enter an address...",
138
+ )
139
+ col_s, col_e = st.columns(2)
140
+ with col_s:
141
+ start_date = st.date_input("Start date", pd.Timestamp(2024, 1, 1))
142
+ with col_e:
143
+ end_date = st.date_input("End date", pd.Timestamp(2025, 11, 10))
144
+
145
+ variable = st.selectbox("Variable", VARIABLES, index=2)
146
+
147
+ st.caption("Data from Open-Meteo archive API")
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Geocode + fetch
151
+ # ---------------------------------------------------------------------------
152
+ try:
153
+ lat, lon = geocode(address)
154
+ except Exception:
155
+ st.error(
156
+ "Could not find that address. Please check the spelling and try again.",
157
+ icon=":material/error:",
158
+ )
159
+ st.stop()
160
+
161
+ start_str = start_date.strftime("%Y-%m-%d")
162
+ end_str = end_date.strftime("%Y-%m-%d")
163
+
164
+ with st.spinner("Fetching weather data..."):
165
+ try:
166
+ hourly = get_weather_data(lat, lon, start_str, end_str)
167
+ except Exception as exc:
168
+ st.error(f"Failed to fetch weather data: {exc}", icon=":material/error:")
169
+ st.stop()
170
+
171
+ daily = aggregate_daily(hourly)
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Header
175
+ # ---------------------------------------------------------------------------
176
+ col_var = VAR_COLS[variable]
177
+ unit = VAR_UNITS[variable]
178
+
179
+ st.markdown("# :material/thermostat: Weather dashboard")
180
+ st.caption(f"{address} ({lat:.4f}, {lon:.4f})")
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # KPI metrics row
184
+ # ---------------------------------------------------------------------------
185
+ if variable == "Precipitation":
186
+ total_precip = daily["precipitation_sum"].sum()
187
+ avg_daily = daily["precipitation_sum"].mean()
188
+ max_daily = daily["precipitation_sum"].max()
189
+ dry_days = int((daily["precipitation_sum"] < 0.01).sum())
190
+
191
+ k1, k2, k3, k4 = st.columns(4)
192
+ k1.metric("Total precipitation", f"{total_precip:.1f} {unit}", border=True)
193
+ k2.metric("Avg daily", f"{avg_daily:.2f} {unit}", border=True)
194
+ k3.metric("Max daily", f"{max_daily:.2f} {unit}", border=True)
195
+ k4.metric("Dry days", f"{dry_days:,}", border=True)
196
+ else:
197
+ mean_col = f"{col_var}_mean"
198
+ min_col = f"{col_var}_min"
199
+ max_col = f"{col_var}_max"
200
+
201
+ overall_mean = daily[mean_col].mean()
202
+ overall_min = daily[min_col].min()
203
+ overall_max = daily[max_col].max()
204
+
205
+ k1, k2, k3, k4 = st.columns(4)
206
+ k1.metric(
207
+ f"Avg {variable.lower()}",
208
+ f"{overall_mean:.1f} {unit}",
209
+ border=True,
210
+ )
211
+ k2.metric(f"Min {variable.lower()}", f"{overall_min:.1f} {unit}", border=True)
212
+ k3.metric(f"Max {variable.lower()}", f"{overall_max:.1f} {unit}", border=True)
213
+ k4.metric("Days of data", f"{len(daily):,}", border=True)
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Filters row
217
+ # ---------------------------------------------------------------------------
218
+ with st.popover("Filters", icon=":material/filter_list:"):
219
+ time_range = st.segmented_control("Time range", TIME_RANGES, default="All")
220
+ agg_mode = st.segmented_control(
221
+ "Aggregation", ["Hourly", "Daily"], default="Daily"
222
+ )
223
+ if variable != "Precipitation":
224
+ show_range = st.toggle("Show min/max range", value=True)
225
+ else:
226
+ show_range = False
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Build filtered data
230
+ # ---------------------------------------------------------------------------
231
+ if agg_mode == "Hourly":
232
+ chart_df = hourly[["time", col_var]].copy()
233
+ chart_df = filter_by_time_range(chart_df, "time", time_range)
234
+ x_field = "time"
235
+ else:
236
+ chart_df = daily.reset_index().copy()
237
+ chart_df = filter_by_time_range(chart_df, "date", time_range)
238
+ x_field = "date"
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Main charts row
242
+ # ---------------------------------------------------------------------------
243
+ col1, col2 = st.columns([3, 1])
244
+
245
+ with col1:
246
+ with st.container(border=True):
247
+ st.markdown(f"**{variable} over time**")
248
+
249
+ if agg_mode == "Hourly":
250
+ chart = (
251
+ alt.Chart(chart_df)
252
+ .mark_line(strokeWidth=1.5)
253
+ .encode(
254
+ x=alt.X("time:T", title="Date"),
255
+ y=alt.Y(f"{col_var}:Q", title=f"{variable} ({unit})"),
256
+ tooltip=[
257
+ alt.Tooltip("time:T", title="Time"),
258
+ alt.Tooltip(f"{col_var}:Q", title=variable, format=".1f"),
259
+ ],
260
+ )
261
+ .properties(height=380)
262
+ )
263
+ st.altair_chart(chart, use_container_width=True)
264
+
265
+ elif variable == "Precipitation":
266
+ chart = (
267
+ alt.Chart(chart_df)
268
+ .mark_bar(color="#4B9CD3")
269
+ .encode(
270
+ x=alt.X("date:T", title="Date"),
271
+ y=alt.Y("precipitation_sum:Q", title=f"Daily total ({unit})"),
272
+ tooltip=[
273
+ alt.Tooltip("date:T", title="Date"),
274
+ alt.Tooltip(
275
+ "precipitation_sum:Q",
276
+ title="Precipitation",
277
+ format=".2f",
278
+ ),
279
+ ],
280
+ )
281
+ .properties(height=380)
282
+ )
283
+ st.altair_chart(chart, use_container_width=True)
284
+
285
+ else:
286
+ mean_c = f"{col_var}_mean"
287
+ min_c = f"{col_var}_min"
288
+ max_c = f"{col_var}_max"
289
+
290
+ line = (
291
+ alt.Chart(chart_df)
292
+ .mark_line(strokeWidth=2)
293
+ .encode(
294
+ x=alt.X("date:T", title="Date"),
295
+ y=alt.Y(f"{mean_c}:Q", title=f"{variable} ({unit})"),
296
+ tooltip=[
297
+ alt.Tooltip("date:T", title="Date"),
298
+ alt.Tooltip(f"{min_c}:Q", title="Min", format=".1f"),
299
+ alt.Tooltip(f"{mean_c}:Q", title="Mean", format=".1f"),
300
+ alt.Tooltip(f"{max_c}:Q", title="Max", format=".1f"),
301
+ ],
302
+ )
303
+ )
304
+
305
+ if show_range:
306
+ band = (
307
+ alt.Chart(chart_df)
308
+ .mark_area(opacity=0.15)
309
+ .encode(
310
+ x=alt.X("date:T"),
311
+ y=alt.Y(f"{min_c}:Q"),
312
+ y2=alt.Y2(f"{max_c}:Q"),
313
+ )
314
+ )
315
+ chart = (band + line).properties(height=380)
316
+ else:
317
+ chart = line.properties(height=380)
318
+
319
+ st.altair_chart(chart, use_container_width=True)
320
+
321
+ with col2:
322
+ with st.container(border=True):
323
+ st.markdown("**Monthly summary**")
324
+
325
+ monthly = hourly.copy()
326
+ monthly["month"] = monthly["time"].dt.to_period("M").astype(str)
327
+
328
+ if variable == "Precipitation":
329
+ monthly_agg = (
330
+ monthly.groupby("month")["precipitation"].sum().reset_index()
331
+ )
332
+ monthly_agg.columns = ["month", "value"]
333
+ else:
334
+ monthly_agg = monthly.groupby("month")[col_var].mean().reset_index()
335
+ monthly_agg.columns = ["month", "value"]
336
+
337
+ bar = (
338
+ alt.Chart(monthly_agg)
339
+ .mark_bar()
340
+ .encode(
341
+ x=alt.X("month:O", title="Month", axis=alt.Axis(labelAngle=-45)),
342
+ y=alt.Y(
343
+ "value:Q",
344
+ title=f"{'Total' if variable == 'Precipitation' else 'Avg'} ({unit})",
345
+ ),
346
+ tooltip=[
347
+ alt.Tooltip("month:O", title="Month"),
348
+ alt.Tooltip("value:Q", title=variable, format=".1f"),
349
+ ],
350
+ )
351
+ .properties(height=380)
352
+ )
353
+ st.altair_chart(bar, use_container_width=True)
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # Bottom section: distribution + data table
357
+ # ---------------------------------------------------------------------------
358
+ col_left, col_right = st.columns(2)
359
+
360
+ with col_left:
361
+ with st.container(border=True):
362
+ st.markdown("**Distribution**")
363
+
364
+ if agg_mode == "Hourly":
365
+ hist_data = chart_df[col_var].dropna()
366
+ else:
367
+ if variable == "Precipitation":
368
+ hist_data = chart_df["precipitation_sum"].dropna()
369
+ else:
370
+ hist_data = chart_df[f"{col_var}_mean"].dropna()
371
+
372
+ hist_df = pd.DataFrame({"value": hist_data})
373
+ hist = (
374
+ alt.Chart(hist_df)
375
+ .mark_bar()
376
+ .encode(
377
+ x=alt.X(
378
+ "value:Q",
379
+ bin=alt.Bin(maxbins=40),
380
+ title=f"{variable} ({unit})",
381
+ ),
382
+ y=alt.Y("count()", title="Frequency"),
383
+ tooltip=[
384
+ alt.Tooltip(
385
+ "value:Q", bin=alt.Bin(maxbins=40), title=variable
386
+ ),
387
+ alt.Tooltip("count()", title="Count"),
388
+ ],
389
+ )
390
+ .properties(height=280)
391
+ )
392
+ st.altair_chart(hist, use_container_width=True)
393
+
394
+ with col_right:
395
+ with st.container(border=True):
396
+ st.markdown("**Raw data**")
397
+
398
+ display_df = chart_df.copy()
399
+
400
+ st.dataframe(
401
+ display_df,
402
+ height=280,
403
+ hide_index=True,
404
+ column_config={
405
+ "date": st.column_config.DateColumn(
406
+ "Date", format="MMM DD, YYYY"
407
+ ),
408
+ "time": st.column_config.DatetimeColumn(
409
+ "Time", format="MMM DD, YYYY HH:mm"
410
+ ),
411
+ },
412
+ )
413
+
414
+ csv = to_csv(display_df)
415
+ st.download_button(
416
+ label="Download CSV",
417
+ data=csv,
418
+ file_name=f"weather_{start_str}_to_{end_str}.csv",
419
+ mime="text/csv",
420
+ icon=":material/download:",
421
+ )
requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
- altair
2
- pandas
3
- streamlit
 
 
1
+ altair>=5.2.0
2
+ geopy>=2.4.0
3
+ pandas>=2.0.0
4
+ streamlit>=1.45.0