air quality: API routes + frontend heatmap overlay (pending Cobb approval)
This commit is contained in:
parent
28cacd540f
commit
33cdc3a50c
2 changed files with 690 additions and 0 deletions
145
docs/AIR-API-PATCH.py
Normal file
145
docs/AIR-API-PATCH.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
AIR QUALITY API — Routes to add to /app/app.py on Rackham (adamaps-api container)
|
||||
Review this file, then apply via: docker exec adamaps-api python3 /tmp/apply_air_patch.py
|
||||
DO NOT apply until Cobb approves.
|
||||
"""
|
||||
|
||||
# ─── EPA AQI calculation ──────────────────────────────────────────────────────
|
||||
def pm25_to_aqi(pm25):
|
||||
if pm25 is None: return None
|
||||
breakpoints = [
|
||||
(0.0, 12.0, 0, 50),
|
||||
(12.1, 35.4, 51, 100),
|
||||
(35.5, 55.4, 101, 150),
|
||||
(55.5, 150.4, 151, 200),
|
||||
(150.5, 250.4, 201, 300),
|
||||
(250.5, 350.4, 301, 400),
|
||||
(350.5, 500.4, 401, 500),
|
||||
]
|
||||
for c_lo, c_hi, i_lo, i_hi in breakpoints:
|
||||
if c_lo <= pm25 <= c_hi:
|
||||
return round((i_hi - i_lo) / (c_hi - c_lo) * (pm25 - c_lo) + i_lo)
|
||||
return 500 if pm25 > 500 else 0
|
||||
|
||||
def init_air_table():
|
||||
try:
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS air_quality (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id VARCHAR(64),
|
||||
sampled_at TIMESTAMP,
|
||||
lat DOUBLE PRECISION,
|
||||
lon DOUBLE PRECISION,
|
||||
alt DOUBLE PRECISION,
|
||||
gps_fix BOOLEAN DEFAULT FALSE,
|
||||
pm1_0 FLOAT,
|
||||
pm2_5 FLOAT,
|
||||
pm10 FLOAT,
|
||||
temperature_c FLOAT,
|
||||
humidity_pct FLOAT,
|
||||
pressure_hpa FLOAT,
|
||||
gas_resistance_ohm FLOAT,
|
||||
aqi INTEGER,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS air_latlon_idx ON air_quality (lat, lon)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS air_sampled_idx ON air_quality (sampled_at DESC)")
|
||||
conn.commit(); cur.close(); conn.close()
|
||||
except Exception as e:
|
||||
print(f"air table init: {e}")
|
||||
|
||||
# ─── /api/ingest/air ──────────────────────────────────────────────────────────
|
||||
# POST — auth required (X-AdaMaps-Key)
|
||||
# Body: {"device_id": "blackbox-pi", "readings": [{sampled_at, lat, lon, pm2_5_ug_m3, ...}]}
|
||||
# Returns: {"inserted": N}
|
||||
def ingest_air():
|
||||
if request.headers.get("X-AdaMaps-Key") != API_KEY:
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
data = request.json
|
||||
if not data: return jsonify({"error": "invalid"}), 400
|
||||
device_id = data.get("device_id", "unknown")
|
||||
readings = data.get("readings", [data] if "sampled_at" in data else [])
|
||||
if not readings: return jsonify({"error": "no readings"}), 400
|
||||
init_air_table()
|
||||
inserted = 0
|
||||
try:
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
for r in readings:
|
||||
try:
|
||||
pm25 = r.get("pm2_5_ug_m3") or r.get("pm2_5")
|
||||
cur.execute("""
|
||||
INSERT INTO air_quality
|
||||
(device_id, sampled_at, lat, lon, alt, gps_fix,
|
||||
pm1_0, pm2_5, pm10, temperature_c, humidity_pct,
|
||||
pressure_hpa, gas_resistance_ohm, aqi)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (
|
||||
r.get("device_id", device_id), r.get("sampled_at"),
|
||||
r.get("lat"), r.get("lon"), r.get("alt"), r.get("gps_fix", False),
|
||||
r.get("pm1_0_ug_m3"), pm25, r.get("pm10_ug_m3"),
|
||||
r.get("temperature_c"), r.get("humidity_pct"),
|
||||
r.get("pressure_hpa"), r.get("gas_resistance_ohm"),
|
||||
pm25_to_aqi(pm25)
|
||||
))
|
||||
inserted += 1
|
||||
except: conn.rollback()
|
||||
conn.commit(); cur.close(); conn.close()
|
||||
except Exception as e:
|
||||
return jsonify({"error": "db_unavailable", "detail": str(e)}), 503
|
||||
return jsonify({"inserted": inserted, "device_id": device_id})
|
||||
|
||||
# ─── /api/air/heatmap ─────────────────────────────────────────────────────────
|
||||
# GET ?metric=aqi|pm2_5 &hours=24
|
||||
# Returns [[lat, lon, intensity_0_to_1], ...] for Leaflet.heat
|
||||
def air_heatmap():
|
||||
metric = request.args.get("metric", "aqi")
|
||||
hours = request.args.get("hours", 24, type=int)
|
||||
col = "aqi" if metric == "aqi" else "pm2_5"
|
||||
max_val = 300.0 if metric == "aqi" else 150.0
|
||||
try:
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
SELECT lat, lon, {col} FROM air_quality
|
||||
WHERE sampled_at > NOW() - INTERVAL '%s hours'
|
||||
AND lat IS NOT NULL AND lon IS NOT NULL
|
||||
AND {col} IS NOT NULL AND gps_fix = TRUE
|
||||
ORDER BY sampled_at DESC LIMIT 50000
|
||||
""", (hours,))
|
||||
rows = [[float(r[0]), float(r[1]), min(float(r[2]) / max_val, 1.0)]
|
||||
for r in cur.fetchall()]
|
||||
cur.close(); conn.close()
|
||||
return jsonify(rows)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
# ─── /api/air/latest ──────────────────────────────────────────────────────────
|
||||
# GET — most recent reading per device
|
||||
def air_latest():
|
||||
try:
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT ON (device_id)
|
||||
device_id, sampled_at, lat, lon,
|
||||
pm1_0, pm2_5, pm10, temperature_c, humidity_pct, aqi
|
||||
FROM air_quality
|
||||
WHERE sampled_at > NOW() - INTERVAL '1 hour'
|
||||
ORDER BY device_id, sampled_at DESC
|
||||
""")
|
||||
rows = [{"device_id": r[0], "sampled_at": r[1].isoformat() if r[1] else None,
|
||||
"lat": float(r[2]) if r[2] else None, "lon": float(r[3]) if r[3] else None,
|
||||
"pm1_0": r[4], "pm2_5": r[5], "pm10": r[6],
|
||||
"temperature_c": r[7], "humidity_pct": r[8], "aqi": r[9]}
|
||||
for r in cur.fetchall()]
|
||||
cur.close(); conn.close()
|
||||
return jsonify(rows)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
# ─── Flask route registration (add to app after existing routes) ───────────────
|
||||
# app.add_url_rule("/api/ingest/air", "ingest_air", ingest_air, methods=["POST"])
|
||||
# app.add_url_rule("/api/air/heatmap", "air_heatmap", air_heatmap, methods=["GET"])
|
||||
# app.add_url_rule("/api/air/latest", "air_latest", air_latest, methods=["GET"])
|
||||
Loading…
Add table
Add a link
Reference in a new issue