Update app.py
Browse files
app.py
CHANGED
@@ -1,19 +1,21 @@
|
|
1 |
import gradio as gr
|
2 |
import requests
|
3 |
import folium
|
4 |
-
import
|
5 |
import time
|
6 |
import os
|
7 |
-
|
8 |
-
import
|
|
|
9 |
from datetime import datetime, timedelta
|
10 |
import pytz
|
11 |
|
12 |
-
class
|
13 |
-
"""
|
14 |
|
15 |
def __init__(self):
|
16 |
-
self.
|
|
|
17 |
self.aqi_colors = {
|
18 |
"Good": "#00E400",
|
19 |
"Moderate": "#FFFF00",
|
@@ -30,21 +32,97 @@ class AirQualityMapper:
|
|
30 |
(201, 300): "Very Unhealthy",
|
31 |
(301, 500): "Hazardous"
|
32 |
}
|
33 |
-
#
|
34 |
-
self.
|
35 |
-
|
36 |
-
def
|
37 |
-
"""
|
38 |
-
|
|
|
|
|
|
|
39 |
try:
|
40 |
-
#
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
|
49 |
def get_aqi_category(self, aqi_value: int) -> str:
|
50 |
"""Get AQI category based on value"""
|
@@ -53,14 +131,9 @@ class AirQualityMapper:
|
|
53 |
return category
|
54 |
return "Unknown"
|
55 |
|
56 |
-
def get_aqi_color(self, category: str) -> str:
|
57 |
-
"""Get color for AQI category"""
|
58 |
-
return self.aqi_colors.get(category, "#808080")
|
59 |
-
|
60 |
def calculate_aqi(self, parameter: str, value: float) -> int:
|
61 |
"""Calculate AQI for common parameters"""
|
62 |
if parameter == 'OZONE' and value > 0:
|
63 |
-
# Simplified ozone AQI calculation (assuming ppb)
|
64 |
if value <= 54: return int((50/54) * value)
|
65 |
elif value <= 70: return int(51 + (49/16) * (value - 54))
|
66 |
elif value <= 85: return int(101 + (49/15) * (value - 70))
|
@@ -68,7 +141,6 @@ class AirQualityMapper:
|
|
68 |
else: return int(201 + (199/95) * min(value - 105, 95))
|
69 |
|
70 |
elif parameter == 'PM2.5' and value >= 0:
|
71 |
-
# PM2.5 AQI calculation
|
72 |
if value <= 12.0: return int((50/12) * value)
|
73 |
elif value <= 35.4: return int(51 + (49/23.4) * (value - 12))
|
74 |
elif value <= 55.4: return int(101 + (49/20) * (value - 35.4))
|
@@ -76,24 +148,19 @@ class AirQualityMapper:
|
|
76 |
else: return int(201 + (199/149.6) * min(value - 150.4, 149.6))
|
77 |
|
78 |
elif parameter == 'PM10' and value >= 0:
|
79 |
-
# PM10 AQI calculation
|
80 |
if value <= 54: return int((50/54) * value)
|
81 |
elif value <= 154: return int(51 + (49/100) * (value - 54))
|
82 |
elif value <= 254: return int(101 + (49/100) * (value - 154))
|
83 |
elif value <= 354: return int(151 + (49/100) * (value - 254))
|
84 |
else: return int(201 + (199/146) * min(value - 354, 146))
|
85 |
|
86 |
-
return 0
|
87 |
|
88 |
def fetch_airnow_bulk_data(self) -> Tuple[List[Dict], str]:
|
89 |
-
"""
|
90 |
-
|
91 |
-
"""
|
92 |
-
print("π― BULK FILE ACCESS: Getting hourly data files with ALL monitoring sites...")
|
93 |
|
94 |
try:
|
95 |
-
all_data = []
|
96 |
-
|
97 |
# Get current GMT time
|
98 |
gmt_now = datetime.now(pytz.UTC)
|
99 |
|
@@ -103,94 +170,82 @@ class AirQualityMapper:
|
|
103 |
target_time = gmt_now - timedelta(hours=hour_offset)
|
104 |
filename = f"HourlyData_{target_time.strftime('%Y%m%d%H')}.dat"
|
105 |
|
106 |
-
|
107 |
-
url = f"{self.base_url}/airnow/today/{filename}"
|
108 |
print(f"π Trying: {url}")
|
109 |
|
110 |
response = requests.get(url, timeout=30)
|
111 |
|
112 |
if response.status_code == 200 and response.text.strip():
|
113 |
-
print(f"β
|
114 |
|
115 |
-
# Parse the
|
116 |
-
|
117 |
-
|
|
|
|
|
|
|
118 |
|
119 |
-
if file_data:
|
120 |
-
print(f"π Parsed {len(file_data)} station records from {filename}")
|
121 |
-
break # Got data, stop trying
|
122 |
-
|
123 |
-
# Also try the date-specific directory
|
124 |
-
date_dir = target_time.strftime('%Y/%Y%m%d')
|
125 |
-
url_dated = f"{self.base_url}/airnow/{date_dir}/{filename}"
|
126 |
-
|
127 |
-
response = requests.get(url_dated, timeout=30)
|
128 |
-
if response.status_code == 200 and response.text.strip():
|
129 |
-
print(f"β
SUCCESS: Found data in dated directory")
|
130 |
-
file_data = self.parse_hourly_data_file(response.text)
|
131 |
-
all_data.extend(file_data)
|
132 |
-
if file_data:
|
133 |
-
print(f"π Parsed {len(file_data)} station records from dated directory")
|
134 |
-
break
|
135 |
-
|
136 |
except Exception as e:
|
137 |
print(f"β Error trying hour {hour_offset}: {str(e)}")
|
138 |
continue
|
139 |
|
140 |
time.sleep(0.1)
|
141 |
|
142 |
-
|
143 |
-
return [], "β οΈ No bulk data files accessible at this time."
|
144 |
-
|
145 |
-
# Filter for air quality parameters only (AQI-relevant)
|
146 |
-
aqi_data = [item for item in all_data if item.get('ParameterName') in ['OZONE', 'PM2.5', 'PM10', 'NO2', 'SO2', 'CO']]
|
147 |
-
|
148 |
-
print(f"π FINAL RESULT: {len(aqi_data)} air quality monitoring stations from bulk files")
|
149 |
-
|
150 |
-
return aqi_data, f"π― BULK SUCCESS: Found {len(aqi_data)} air quality monitoring stations (from {len(all_data)} total sensors)"
|
151 |
|
152 |
except Exception as e:
|
153 |
-
|
154 |
-
return [], f"β Error accessing bulk files: {str(e)}"
|
155 |
|
156 |
def parse_hourly_data_file(self, text: str) -> List[Dict]:
|
157 |
-
"""Parse
|
158 |
lines = text.strip().split('\n')
|
159 |
data = []
|
160 |
|
|
|
|
|
|
|
|
|
161 |
for line in lines:
|
162 |
if not line.strip():
|
163 |
continue
|
164 |
|
165 |
try:
|
166 |
-
# Split by pipe delimiter as per official format
|
167 |
fields = line.split('|')
|
168 |
|
169 |
-
if len(fields) >= 9:
|
170 |
-
#
|
171 |
-
|
172 |
-
|
|
|
|
|
|
|
|
|
|
|
173 |
|
174 |
value = float(fields[7]) if fields[7].replace('.','').replace('-','').isdigit() else 0
|
175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
176 |
|
177 |
record = {
|
178 |
-
'DateObserved': fields[0],
|
179 |
-
'HourObserved': fields[1],
|
180 |
-
'AQSID':
|
181 |
-
'SiteName': fields[3],
|
182 |
-
'
|
183 |
-
'
|
184 |
-
'ReportingUnits': fields[6], # Units
|
185 |
'Value': value,
|
186 |
'DataSource': fields[8] if len(fields) > 8 else '',
|
187 |
'Latitude': lat,
|
188 |
'Longitude': lon,
|
189 |
'AQI': aqi,
|
190 |
'Category': {'Name': self.get_aqi_category(aqi)},
|
191 |
-
'ReportingArea': fields[3],
|
192 |
-
'StateCode':
|
193 |
-
'source': 'bulk_hourly_file'
|
194 |
}
|
195 |
|
196 |
data.append(record)
|
@@ -198,114 +253,55 @@ class AirQualityMapper:
|
|
198 |
except Exception as e:
|
199 |
continue
|
200 |
|
|
|
201 |
return data
|
202 |
|
203 |
-
def estimate_coordinates(self, site_name: str, gmt_offset: int) -> Tuple[float, float]:
|
204 |
-
"""Estimate coordinates based on site name and timezone"""
|
205 |
-
# Use GMT offset to estimate longitude (very rough)
|
206 |
-
base_lon = -98.0 + (gmt_offset * 15) # Rough longitude from timezone
|
207 |
-
|
208 |
-
# Use site name to refine coordinates (basic heuristics)
|
209 |
-
site_lower = site_name.lower()
|
210 |
-
|
211 |
-
# Regional adjustments based on common city/state names
|
212 |
-
if any(word in site_lower for word in ['california', 'ca', 'los angeles', 'san francisco', 'san diego']):
|
213 |
-
return 34.0, -118.0
|
214 |
-
elif any(word in site_lower for word in ['texas', 'tx', 'houston', 'dallas', 'austin']):
|
215 |
-
return 31.0, -97.0
|
216 |
-
elif any(word in site_lower for word in ['florida', 'fl', 'miami', 'tampa', 'orlando']):
|
217 |
-
return 27.0, -82.0
|
218 |
-
elif any(word in site_lower for word in ['new york', 'ny', 'manhattan', 'brooklyn']):
|
219 |
-
return 40.7, -74.0
|
220 |
-
elif any(word in site_lower for word in ['washington', 'wa', 'seattle']):
|
221 |
-
return 47.6, -122.3
|
222 |
-
elif any(word in site_lower for word in ['oregon', 'or', 'portland']):
|
223 |
-
return 45.5, -122.7
|
224 |
-
elif any(word in site_lower for word in ['colorado', 'co', 'denver']):
|
225 |
-
return 39.7, -105.0
|
226 |
-
elif any(word in site_lower for word in ['arizona', 'az', 'phoenix']):
|
227 |
-
return 33.4, -112.1
|
228 |
-
elif any(word in site_lower for word in ['nevada', 'nv', 'las vegas']):
|
229 |
-
return 36.2, -115.1
|
230 |
-
elif any(word in site_lower for word in ['utah', 'ut', 'salt lake']):
|
231 |
-
return 40.8, -111.9
|
232 |
-
elif any(word in site_lower for word in ['canada', 'ontario', 'quebec']):
|
233 |
-
return 45.0 + (gmt_offset + 5) * 2, base_lon
|
234 |
-
else:
|
235 |
-
# Default to center US with longitude adjustment
|
236 |
-
return 39.0 + (hash(site_name) % 20 - 10), base_lon
|
237 |
-
|
238 |
-
def extract_state_from_source(self, source: str) -> str:
|
239 |
-
"""Extract state code from data source string"""
|
240 |
-
# Common state abbreviations and agency names
|
241 |
-
if 'california' in source.lower() or 'ca' in source.lower():
|
242 |
-
return 'CA'
|
243 |
-
elif 'texas' in source.lower() or 'tx' in source.lower():
|
244 |
-
return 'TX'
|
245 |
-
elif 'florida' in source.lower() or 'fl' in source.lower():
|
246 |
-
return 'FL'
|
247 |
-
elif 'new york' in source.lower() or 'ny' in source.lower():
|
248 |
-
return 'NY'
|
249 |
-
elif 'canada' in source.lower():
|
250 |
-
return 'CN'
|
251 |
-
else:
|
252 |
-
return 'US' # Default
|
253 |
-
|
254 |
def create_map(self, data: List[Dict]) -> str:
|
255 |
-
"""Create
|
256 |
if not data:
|
257 |
-
# Create a basic US map if no data
|
258 |
m = folium.Map(location=[39.8283, -98.5795], zoom_start=4)
|
259 |
folium.Marker(
|
260 |
[39.8283, -98.5795],
|
261 |
-
popup="No data available.
|
262 |
icon=folium.Icon(color='red', icon='info-sign')
|
263 |
).add_to(m)
|
264 |
return m._repr_html_()
|
265 |
|
266 |
-
# Calculate center
|
267 |
-
lats = [item['Latitude'] for item in data
|
268 |
-
lons = [item['Longitude'] for item in data
|
269 |
-
|
270 |
-
|
271 |
-
center_lat = sum(lats) / len(lats)
|
272 |
-
center_lon = sum(lons) / len(lons)
|
273 |
-
else:
|
274 |
-
center_lat, center_lon = 39.8283, -98.5795 # Center of US
|
275 |
|
276 |
# Create map
|
277 |
m = folium.Map(location=[center_lat, center_lon], zoom_start=4)
|
278 |
|
279 |
-
# Add markers
|
280 |
for item in data:
|
281 |
try:
|
282 |
-
lat = item
|
283 |
-
lon = item
|
284 |
-
aqi = item
|
285 |
-
parameter = item
|
286 |
-
site_name = item
|
287 |
-
value = item
|
288 |
-
units = item
|
289 |
-
|
290 |
-
category = item.get('Category', {}).get('Name', self.get_aqi_category(aqi))
|
291 |
|
292 |
-
|
293 |
-
continue
|
294 |
-
|
295 |
-
# Create popup content
|
296 |
popup_content = f"""
|
297 |
<div style="width: 250px;">
|
298 |
<h4>{site_name}</h4>
|
299 |
<p><b>Parameter:</b> {parameter}</p>
|
300 |
<p><b>Value:</b> {value} {units}</p>
|
301 |
<p><b>AQI:</b> {aqi} ({category})</p>
|
302 |
-
<p><b>
|
303 |
-
<p><b>Time:</b> {item
|
304 |
-
<p><b>
|
305 |
</div>
|
306 |
"""
|
307 |
|
308 |
-
#
|
309 |
if aqi <= 50:
|
310 |
marker_color = 'green'
|
311 |
elif aqi <= 100:
|
@@ -323,17 +319,17 @@ class AirQualityMapper:
|
|
323 |
folium.Marker(
|
324 |
[lat, lon],
|
325 |
popup=folium.Popup(popup_content, max_width=300),
|
326 |
-
tooltip=f"{site_name}: {parameter} = {value} {units}",
|
327 |
icon=folium.Icon(color=marker_color, icon='cloud')
|
328 |
).add_to(m)
|
329 |
|
330 |
except Exception as e:
|
331 |
-
continue
|
332 |
|
333 |
# Add legend
|
334 |
legend_html = """
|
335 |
<div style="position: fixed;
|
336 |
-
bottom: 50px; left: 50px; width:
|
337 |
background-color: white; border:2px solid grey; z-index:9999;
|
338 |
font-size:14px; padding: 10px">
|
339 |
<h4>AQI Legend</h4>
|
@@ -350,86 +346,84 @@ class AirQualityMapper:
|
|
350 |
return m._repr_html_()
|
351 |
|
352 |
def create_data_table(self, data: List[Dict]) -> pd.DataFrame:
|
353 |
-
"""Create
|
354 |
if not data:
|
355 |
return pd.DataFrame()
|
356 |
|
357 |
-
# Extract relevant columns
|
358 |
table_data = []
|
359 |
for item in data:
|
360 |
table_data.append({
|
361 |
-
'Site Name': item
|
362 |
-
'
|
363 |
-
'
|
364 |
-
'
|
365 |
-
'
|
366 |
-
'
|
367 |
-
'
|
368 |
-
'
|
369 |
-
'
|
370 |
-
'
|
371 |
-
'
|
|
|
372 |
})
|
373 |
|
374 |
df = pd.DataFrame(table_data)
|
375 |
return df.sort_values('AQI', ascending=False)
|
376 |
|
377 |
-
# Initialize
|
378 |
-
mapper =
|
379 |
|
380 |
def update_map():
|
381 |
-
"""Update
|
|
|
382 |
|
383 |
-
# Fetch
|
384 |
data, status = mapper.fetch_airnow_bulk_data()
|
385 |
|
386 |
# Create map
|
387 |
map_html = mapper.create_map(data)
|
388 |
|
389 |
-
# Create
|
390 |
df = mapper.create_data_table(data)
|
391 |
|
392 |
return map_html, df, status
|
393 |
|
394 |
# Create Gradio interface
|
395 |
-
with gr.Blocks(title="AirNow
|
396 |
|
397 |
gr.Markdown(
|
398 |
-
|
399 |
-
#
|
400 |
-
|
401 |
-
**β
NO API KEY REQUIRED** - Uses public bulk data files!
|
402 |
|
403 |
-
|
404 |
-
The system automatically fetches hourly data files containing readings from 2,000+ monitoring stations.
|
405 |
|
406 |
-
|
407 |
-
1. **
|
408 |
-
2. **
|
409 |
-
3. **
|
410 |
-
4. **No Rate Limits**: Uses public file access, not API calls
|
411 |
|
412 |
-
##
|
413 |
-
-
|
414 |
-
-
|
415 |
-
-
|
416 |
-
-
|
417 |
-
-
|
418 |
|
419 |
-
**β οΈ Note**:
|
|
|
420 |
"""
|
421 |
)
|
422 |
|
423 |
with gr.Row():
|
424 |
-
load_button = gr.Button("
|
425 |
|
426 |
-
status_text = gr.Markdown("Click the button above to load current air quality data
|
427 |
|
428 |
with gr.Tabs():
|
429 |
-
with gr.TabItem("πΊοΈ
|
430 |
-
map_output = gr.HTML(label="Air Quality Map")
|
431 |
|
432 |
-
with gr.TabItem("π Data
|
433 |
data_table = gr.Dataframe(
|
434 |
label="Air Quality Monitoring Stations",
|
435 |
interactive=False
|
@@ -437,17 +431,20 @@ with gr.Blocks(title="AirNow Bulk Data Sensor Map", theme=gr.themes.Soft()) as d
|
|
437 |
|
438 |
gr.Markdown(
|
439 |
"""
|
440 |
-
## Data
|
441 |
|
442 |
-
**
|
443 |
-
**
|
444 |
**Coverage**: 2,000+ monitoring stations across US, Canada, and parts of Mexico
|
445 |
-
|
|
|
|
|
|
|
446 |
|
447 |
## Links:
|
448 |
-
- [
|
449 |
-
- [
|
450 |
-
- [
|
451 |
"""
|
452 |
)
|
453 |
|
@@ -458,6 +455,5 @@ with gr.Blocks(title="AirNow Bulk Data Sensor Map", theme=gr.themes.Soft()) as d
|
|
458 |
outputs=[map_output, data_table, status_text]
|
459 |
)
|
460 |
|
461 |
-
# Launch the app
|
462 |
if __name__ == "__main__":
|
463 |
demo.launch()
|
|
|
1 |
import gradio as gr
|
2 |
import requests
|
3 |
import folium
|
4 |
+
import pandas as pd
|
5 |
import time
|
6 |
import os
|
7 |
+
import zipfile
|
8 |
+
import io
|
9 |
+
from typing import Dict, List, Tuple
|
10 |
from datetime import datetime, timedelta
|
11 |
import pytz
|
12 |
|
13 |
+
class AccurateAirQualityMapper:
|
14 |
+
"""Air Quality Mapper with precise EPA coordinates"""
|
15 |
|
16 |
def __init__(self):
|
17 |
+
self.airnow_base_url = "https://files.airnowtech.org"
|
18 |
+
self.epa_base_url = "https://aqs.epa.gov/aqsweb/airdata"
|
19 |
self.aqi_colors = {
|
20 |
"Good": "#00E400",
|
21 |
"Moderate": "#FFFF00",
|
|
|
32 |
(201, 300): "Very Unhealthy",
|
33 |
(301, 500): "Hazardous"
|
34 |
}
|
35 |
+
# Cache for coordinate lookups
|
36 |
+
self.coordinate_cache = {}
|
37 |
+
|
38 |
+
def download_epa_coordinates(self) -> Dict[str, Tuple[float, float]]:
|
39 |
+
"""Download EPA monitor coordinates and create lookup dictionary"""
|
40 |
+
|
41 |
+
print("πΊοΈ Downloading EPA monitor coordinates...")
|
42 |
+
coordinates = {}
|
43 |
+
|
44 |
try:
|
45 |
+
# Download monitor listing (most comprehensive)
|
46 |
+
monitors_url = f"{self.epa_base_url}/aqs_monitors.zip"
|
47 |
+
print(f"Downloading: {monitors_url}")
|
48 |
+
|
49 |
+
response = requests.get(monitors_url, timeout=60)
|
50 |
+
if response.status_code == 200:
|
51 |
+
# Extract CSV from ZIP
|
52 |
+
with zipfile.ZipFile(io.BytesIO(response.content)) as z:
|
53 |
+
csv_filename = z.namelist()[0] # Should be monitors.csv
|
54 |
+
with z.open(csv_filename) as f:
|
55 |
+
# Read CSV with pandas
|
56 |
+
df = pd.read_csv(f)
|
57 |
+
|
58 |
+
print(f"π Loaded {len(df)} monitor records")
|
59 |
+
print(f"Columns: {list(df.columns)}")
|
60 |
+
|
61 |
+
# Create lookup by AQS ID (State+County+Site+Parameter+POC)
|
62 |
+
for _, row in df.iterrows():
|
63 |
+
try:
|
64 |
+
# Build AQS ID from components
|
65 |
+
state_code = str(row.get('State Code', '')).zfill(2)
|
66 |
+
county_code = str(row.get('County Code', '')).zfill(3)
|
67 |
+
site_number = str(row.get('Site Number', '')).zfill(4)
|
68 |
+
|
69 |
+
aqs_id = f"{state_code}{county_code}{site_number}"
|
70 |
+
|
71 |
+
# Get coordinates
|
72 |
+
lat = float(row.get('Latitude', 0))
|
73 |
+
lon = float(row.get('Longitude', 0))
|
74 |
+
|
75 |
+
if lat != 0 and lon != 0 and aqs_id != "0000000":
|
76 |
+
coordinates[aqs_id] = (lat, lon)
|
77 |
+
|
78 |
+
except (ValueError, TypeError):
|
79 |
+
continue
|
80 |
+
|
81 |
+
print(f"β
Created coordinate lookup for {len(coordinates)} stations")
|
82 |
+
|
83 |
+
else:
|
84 |
+
print(f"β Failed to download monitors: HTTP {response.status_code}")
|
85 |
+
|
86 |
+
except Exception as e:
|
87 |
+
print(f"β Error downloading EPA coordinates: {str(e)}")
|
88 |
+
|
89 |
+
# Fallback: try sites file
|
90 |
+
if len(coordinates) < 1000: # If we didn't get enough coordinates
|
91 |
+
try:
|
92 |
+
print("π Trying sites file as backup...")
|
93 |
+
sites_url = f"{self.epa_base_url}/aqs_sites.zip"
|
94 |
+
|
95 |
+
response = requests.get(sites_url, timeout=60)
|
96 |
+
if response.status_code == 200:
|
97 |
+
with zipfile.ZipFile(io.BytesIO(response.content)) as z:
|
98 |
+
csv_filename = z.namelist()[0]
|
99 |
+
with z.open(csv_filename) as f:
|
100 |
+
df = pd.read_csv(f)
|
101 |
+
|
102 |
+
for _, row in df.iterrows():
|
103 |
+
try:
|
104 |
+
state_code = str(row.get('State Code', '')).zfill(2)
|
105 |
+
county_code = str(row.get('County Code', '')).zfill(3)
|
106 |
+
site_number = str(row.get('Site Number', '')).zfill(4)
|
107 |
+
|
108 |
+
aqs_id = f"{state_code}{county_code}{site_number}"
|
109 |
+
|
110 |
+
lat = float(row.get('Latitude', 0))
|
111 |
+
lon = float(row.get('Longitude', 0))
|
112 |
+
|
113 |
+
if lat != 0 and lon != 0 and aqs_id not in coordinates:
|
114 |
+
coordinates[aqs_id] = (lat, lon)
|
115 |
+
|
116 |
+
except (ValueError, TypeError):
|
117 |
+
continue
|
118 |
+
|
119 |
+
print(f"β
Added {len(coordinates)} total coordinates")
|
120 |
+
|
121 |
+
except Exception as e:
|
122 |
+
print(f"β Error with sites backup: {str(e)}")
|
123 |
+
|
124 |
+
self.coordinate_cache = coordinates
|
125 |
+
return coordinates
|
126 |
|
127 |
def get_aqi_category(self, aqi_value: int) -> str:
|
128 |
"""Get AQI category based on value"""
|
|
|
131 |
return category
|
132 |
return "Unknown"
|
133 |
|
|
|
|
|
|
|
|
|
134 |
def calculate_aqi(self, parameter: str, value: float) -> int:
|
135 |
"""Calculate AQI for common parameters"""
|
136 |
if parameter == 'OZONE' and value > 0:
|
|
|
137 |
if value <= 54: return int((50/54) * value)
|
138 |
elif value <= 70: return int(51 + (49/16) * (value - 54))
|
139 |
elif value <= 85: return int(101 + (49/15) * (value - 70))
|
|
|
141 |
else: return int(201 + (199/95) * min(value - 105, 95))
|
142 |
|
143 |
elif parameter == 'PM2.5' and value >= 0:
|
|
|
144 |
if value <= 12.0: return int((50/12) * value)
|
145 |
elif value <= 35.4: return int(51 + (49/23.4) * (value - 12))
|
146 |
elif value <= 55.4: return int(101 + (49/20) * (value - 35.4))
|
|
|
148 |
else: return int(201 + (199/149.6) * min(value - 150.4, 149.6))
|
149 |
|
150 |
elif parameter == 'PM10' and value >= 0:
|
|
|
151 |
if value <= 54: return int((50/54) * value)
|
152 |
elif value <= 154: return int(51 + (49/100) * (value - 54))
|
153 |
elif value <= 254: return int(101 + (49/100) * (value - 154))
|
154 |
elif value <= 354: return int(151 + (49/100) * (value - 254))
|
155 |
else: return int(201 + (199/146) * min(value - 354, 146))
|
156 |
|
157 |
+
return 0
|
158 |
|
159 |
def fetch_airnow_bulk_data(self) -> Tuple[List[Dict], str]:
|
160 |
+
"""Fetch current AirNow bulk data"""
|
161 |
+
print("π― Fetching AirNow bulk data...")
|
|
|
|
|
162 |
|
163 |
try:
|
|
|
|
|
164 |
# Get current GMT time
|
165 |
gmt_now = datetime.now(pytz.UTC)
|
166 |
|
|
|
170 |
target_time = gmt_now - timedelta(hours=hour_offset)
|
171 |
filename = f"HourlyData_{target_time.strftime('%Y%m%d%H')}.dat"
|
172 |
|
173 |
+
url = f"{self.airnow_base_url}/airnow/today/{filename}"
|
|
|
174 |
print(f"π Trying: {url}")
|
175 |
|
176 |
response = requests.get(url, timeout=30)
|
177 |
|
178 |
if response.status_code == 200 and response.text.strip():
|
179 |
+
print(f"β
Found data file with {len(response.text.splitlines())} lines")
|
180 |
|
181 |
+
# Parse the data
|
182 |
+
data = self.parse_hourly_data_file(response.text)
|
183 |
+
|
184 |
+
if data:
|
185 |
+
print(f"π Parsed {len(data)} station records")
|
186 |
+
return data, f"β
SUCCESS: {len(data)} monitoring stations from {filename}"
|
187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
188 |
except Exception as e:
|
189 |
print(f"β Error trying hour {hour_offset}: {str(e)}")
|
190 |
continue
|
191 |
|
192 |
time.sleep(0.1)
|
193 |
|
194 |
+
return [], "β No recent data files found"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
195 |
|
196 |
except Exception as e:
|
197 |
+
return [], f"β Error fetching bulk data: {str(e)}"
|
|
|
198 |
|
199 |
def parse_hourly_data_file(self, text: str) -> List[Dict]:
|
200 |
+
"""Parse AirNow hourly data format"""
|
201 |
lines = text.strip().split('\n')
|
202 |
data = []
|
203 |
|
204 |
+
# Download coordinates if not cached
|
205 |
+
if not self.coordinate_cache:
|
206 |
+
self.download_epa_coordinates()
|
207 |
+
|
208 |
for line in lines:
|
209 |
if not line.strip():
|
210 |
continue
|
211 |
|
212 |
try:
|
|
|
213 |
fields = line.split('|')
|
214 |
|
215 |
+
if len(fields) >= 9:
|
216 |
+
aqs_id = fields[2] # AQS ID from file
|
217 |
+
|
218 |
+
# Look up coordinates
|
219 |
+
lat, lon = self.coordinate_cache.get(aqs_id[:9], (0, 0)) # Use first 9 chars (site ID)
|
220 |
+
|
221 |
+
# Skip if no coordinates found
|
222 |
+
if lat == 0 and lon == 0:
|
223 |
+
continue
|
224 |
|
225 |
value = float(fields[7]) if fields[7].replace('.','').replace('-','').isdigit() else 0
|
226 |
+
parameter = fields[5]
|
227 |
+
|
228 |
+
# Only include air quality parameters
|
229 |
+
if parameter not in ['OZONE', 'PM2.5', 'PM10', 'NO2', 'SO2', 'CO']:
|
230 |
+
continue
|
231 |
+
|
232 |
+
aqi = self.calculate_aqi(parameter, value)
|
233 |
|
234 |
record = {
|
235 |
+
'DateObserved': fields[0],
|
236 |
+
'HourObserved': fields[1],
|
237 |
+
'AQSID': aqs_id,
|
238 |
+
'SiteName': fields[3],
|
239 |
+
'ParameterName': parameter,
|
240 |
+
'ReportingUnits': fields[6],
|
|
|
241 |
'Value': value,
|
242 |
'DataSource': fields[8] if len(fields) > 8 else '',
|
243 |
'Latitude': lat,
|
244 |
'Longitude': lon,
|
245 |
'AQI': aqi,
|
246 |
'Category': {'Name': self.get_aqi_category(aqi)},
|
247 |
+
'ReportingArea': fields[3],
|
248 |
+
'StateCode': aqs_id[:2] if len(aqs_id) >= 2 else 'US'
|
|
|
249 |
}
|
250 |
|
251 |
data.append(record)
|
|
|
253 |
except Exception as e:
|
254 |
continue
|
255 |
|
256 |
+
print(f"β
Found coordinates for {len(data)} stations")
|
257 |
return data
|
258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
259 |
def create_map(self, data: List[Dict]) -> str:
|
260 |
+
"""Create interactive map with accurate coordinates"""
|
261 |
if not data:
|
|
|
262 |
m = folium.Map(location=[39.8283, -98.5795], zoom_start=4)
|
263 |
folium.Marker(
|
264 |
[39.8283, -98.5795],
|
265 |
+
popup="No air quality data available.",
|
266 |
icon=folium.Icon(color='red', icon='info-sign')
|
267 |
).add_to(m)
|
268 |
return m._repr_html_()
|
269 |
|
270 |
+
# Calculate center
|
271 |
+
lats = [item['Latitude'] for item in data]
|
272 |
+
lons = [item['Longitude'] for item in data]
|
273 |
+
center_lat = sum(lats) / len(lats)
|
274 |
+
center_lon = sum(lons) / len(lons)
|
|
|
|
|
|
|
|
|
275 |
|
276 |
# Create map
|
277 |
m = folium.Map(location=[center_lat, center_lon], zoom_start=4)
|
278 |
|
279 |
+
# Add markers
|
280 |
for item in data:
|
281 |
try:
|
282 |
+
lat = item['Latitude']
|
283 |
+
lon = item['Longitude']
|
284 |
+
aqi = item['AQI']
|
285 |
+
parameter = item['ParameterName']
|
286 |
+
site_name = item['SiteName']
|
287 |
+
value = item['Value']
|
288 |
+
units = item['ReportingUnits']
|
289 |
+
category = item['Category']['Name']
|
|
|
290 |
|
291 |
+
# Create popup
|
|
|
|
|
|
|
292 |
popup_content = f"""
|
293 |
<div style="width: 250px;">
|
294 |
<h4>{site_name}</h4>
|
295 |
<p><b>Parameter:</b> {parameter}</p>
|
296 |
<p><b>Value:</b> {value} {units}</p>
|
297 |
<p><b>AQI:</b> {aqi} ({category})</p>
|
298 |
+
<p><b>Coordinates:</b> {lat:.4f}, {lon:.4f}</p>
|
299 |
+
<p><b>Time:</b> {item['DateObserved']} {item['HourObserved']}:00 GMT</p>
|
300 |
+
<p><b>Station ID:</b> {item['AQSID']}</p>
|
301 |
</div>
|
302 |
"""
|
303 |
|
304 |
+
# Color based on AQI
|
305 |
if aqi <= 50:
|
306 |
marker_color = 'green'
|
307 |
elif aqi <= 100:
|
|
|
319 |
folium.Marker(
|
320 |
[lat, lon],
|
321 |
popup=folium.Popup(popup_content, max_width=300),
|
322 |
+
tooltip=f"{site_name}: {parameter} = {value} {units} (AQI: {aqi})",
|
323 |
icon=folium.Icon(color=marker_color, icon='cloud')
|
324 |
).add_to(m)
|
325 |
|
326 |
except Exception as e:
|
327 |
+
continue
|
328 |
|
329 |
# Add legend
|
330 |
legend_html = """
|
331 |
<div style="position: fixed;
|
332 |
+
bottom: 50px; left: 50px; width: 180px; height: 200px;
|
333 |
background-color: white; border:2px solid grey; z-index:9999;
|
334 |
font-size:14px; padding: 10px">
|
335 |
<h4>AQI Legend</h4>
|
|
|
346 |
return m._repr_html_()
|
347 |
|
348 |
def create_data_table(self, data: List[Dict]) -> pd.DataFrame:
|
349 |
+
"""Create data table"""
|
350 |
if not data:
|
351 |
return pd.DataFrame()
|
352 |
|
|
|
353 |
table_data = []
|
354 |
for item in data:
|
355 |
table_data.append({
|
356 |
+
'Site Name': item['SiteName'],
|
357 |
+
'State': item['StateCode'],
|
358 |
+
'Parameter': item['ParameterName'],
|
359 |
+
'Value': item['Value'],
|
360 |
+
'Units': item['ReportingUnits'],
|
361 |
+
'AQI': item['AQI'],
|
362 |
+
'Category': item['Category']['Name'],
|
363 |
+
'Latitude': round(item['Latitude'], 4),
|
364 |
+
'Longitude': round(item['Longitude'], 4),
|
365 |
+
'Date': item['DateObserved'],
|
366 |
+
'Hour (GMT)': item['HourObserved'],
|
367 |
+
'Station ID': item['AQSID']
|
368 |
})
|
369 |
|
370 |
df = pd.DataFrame(table_data)
|
371 |
return df.sort_values('AQI', ascending=False)
|
372 |
|
373 |
+
# Initialize mapper
|
374 |
+
mapper = AccurateAirQualityMapper()
|
375 |
|
376 |
def update_map():
|
377 |
+
"""Update map with accurate coordinates"""
|
378 |
+
print("π Starting accurate air quality mapping...")
|
379 |
|
380 |
+
# Fetch data
|
381 |
data, status = mapper.fetch_airnow_bulk_data()
|
382 |
|
383 |
# Create map
|
384 |
map_html = mapper.create_map(data)
|
385 |
|
386 |
+
# Create table
|
387 |
df = mapper.create_data_table(data)
|
388 |
|
389 |
return map_html, df, status
|
390 |
|
391 |
# Create Gradio interface
|
392 |
+
with gr.Blocks(title="Accurate AirNow Sensor Map", theme=gr.themes.Soft()) as demo:
|
393 |
|
394 |
gr.Markdown(
|
395 |
+
"""
|
396 |
+
# π― Accurate AirNow Air Quality Map
|
|
|
|
|
397 |
|
398 |
+
**β
PRECISE COORDINATES** - Uses EPA's official monitor coordinate database!
|
|
|
399 |
|
400 |
+
This map displays real-time air quality data with **accurate station locations** by:
|
401 |
+
1. **Downloading EPA coordinates**: Gets precise lat/lon for every monitoring station
|
402 |
+
2. **Fetching AirNow bulk data**: Current hourly readings from 2,000+ stations
|
403 |
+
3. **Accurate mapping**: Stations plotted at their exact geographic locations
|
|
|
404 |
|
405 |
+
## Key Features:
|
406 |
+
- π― **Precise Locations**: EPA's official coordinate database
|
407 |
+
- π **Complete Coverage**: All active AirNow monitoring stations
|
408 |
+
- β‘ **Real-time Data**: Latest hourly observations
|
409 |
+
- π **Air Quality Focus**: OZONE, PM2.5, PM10, NO2, SO2, CO
|
410 |
+
- π **Auto-updated**: Fresh data every hour
|
411 |
|
412 |
+
**β οΈ Data Note**: This displays preliminary, real-time data for public information.
|
413 |
+
For regulatory purposes, use EPA's official AQS data.
|
414 |
"""
|
415 |
)
|
416 |
|
417 |
with gr.Row():
|
418 |
+
load_button = gr.Button("π― Load Accurate Air Quality Map", variant="primary", size="lg")
|
419 |
|
420 |
+
status_text = gr.Markdown("Click the button above to load current air quality data with precise coordinates.")
|
421 |
|
422 |
with gr.Tabs():
|
423 |
+
with gr.TabItem("πΊοΈ Accurate Map"):
|
424 |
+
map_output = gr.HTML(label="Air Quality Map with Precise Coordinates")
|
425 |
|
426 |
+
with gr.TabItem("π Station Data"):
|
427 |
data_table = gr.Dataframe(
|
428 |
label="Air Quality Monitoring Stations",
|
429 |
interactive=False
|
|
|
431 |
|
432 |
gr.Markdown(
|
433 |
"""
|
434 |
+
## Data Sources:
|
435 |
|
436 |
+
**Coordinates**: EPA Air Quality System (AQS) - Official monitor locations
|
437 |
+
**Air Quality Data**: AirNow hourly bulk files - Real-time observations
|
438 |
**Coverage**: 2,000+ monitoring stations across US, Canada, and parts of Mexico
|
439 |
+
|
440 |
+
## Files Used:
|
441 |
+
- `aqs_monitors.zip` - EPA monitor coordinates (364,377+ records)
|
442 |
+
- `HourlyData_YYYYMMDDHH.dat` - AirNow real-time observations
|
443 |
|
444 |
## Links:
|
445 |
+
- [EPA AQS Data](https://aqs.epa.gov/aqsweb/airdata/download_files.html)
|
446 |
+
- [AirNow Bulk Files](https://files.airnowtech.org/airnow/today/)
|
447 |
+
- [EPA Monitor Map](https://www.epa.gov/outdoor-air-quality-data/interactive-map-air-quality-monitors)
|
448 |
"""
|
449 |
)
|
450 |
|
|
|
455 |
outputs=[map_output, data_table, status_text]
|
456 |
)
|
457 |
|
|
|
458 |
if __name__ == "__main__":
|
459 |
demo.launch()
|