diff --git a/docs/AIR-API-PATCH.py b/docs/AIR-API-PATCH.py new file mode 100644 index 0000000..24a5016 --- /dev/null +++ b/docs/AIR-API-PATCH.py @@ -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"]) diff --git a/docs/adamaps-index-preview.html b/docs/adamaps-index-preview.html new file mode 100644 index 0000000..495e946 --- /dev/null +++ b/docs/adamaps-index-preview.html @@ -0,0 +1,545 @@ + + + + + + AdaMaps — Verified Sign Map + + + + + + + +
+ + + + + + +// AQI color gradient: green→yellow→orange→red→purple +const AQI_GRADIENT = { + 0.0: '#00e400', // Good (0-50) + 0.17: '#ffff00', // Moderate (51-100) + 0.33: '#ff7e00', // Unhealthy for sensitive (101-150) + 0.50: '#ff0000', // Unhealthy (151-200) + 0.67: '#8f3f97', // Very unhealthy (201-300) + 1.0: '#7e0023', // Hazardous (301+) +}; + +const PM25_GRADIENT = { + 0.0: '#00e5ff', + 0.25: '#00ff88', + 0.50: '#ffff00', + 0.75: '#ff7e00', + 1.0: '#ff0000', +}; + +function aqiLabel(aqi) { + if (aqi == null) return '—'; + if (aqi <= 50) return `${aqi} Good`; + if (aqi <= 100) return `${aqi} Moderate`; + if (aqi <= 150) return `${aqi} Unhealthy (Sensitive)`; + if (aqi <= 200) return `${aqi} Unhealthy`; + if (aqi <= 300) return `${aqi} Very Unhealthy`; + return `${aqi} Hazardous`; +} + +function aqiColor(aqi) { + if (aqi == null) return '#888'; + if (aqi <= 50) return '#00e400'; + if (aqi <= 100) return '#ffff00'; + if (aqi <= 150) return '#ff7e00'; + if (aqi <= 200) return '#ff0000'; + if (aqi <= 300) return '#8f3f97'; + return '#7e0023'; +} + +let airHeatAqi = null; +let airHeatPm25 = null; +let airMode = null; // null | 'aqi' | 'pm25' + +async function fetchAirOverlay(metric) { + try { + const res = await fetch( + `https://api.adamaps.org/api/air/heatmap?metric=${metric}&hours=24`, + { cache: 'no-store' } + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); // [[lat, lon, intensity], ...] + } catch (e) { + console.warn('Air overlay fetch failed:', e); + return []; + } +} + +async function toggleAirOverlay(metric) { + // Clear both layers first + if (airHeatAqi) { map.removeLayer(airHeatAqi); airHeatAqi = null; } + if (airHeatPm25) { map.removeLayer(airHeatPm25); airHeatPm25 = null; } + + // Toggle off if same metric clicked again + if (airMode === metric) { + airMode = null; + btnAqi.classList.remove('active'); + btnPm25.classList.remove('active'); + statusEl.textContent = 'live · ' + new Date().toLocaleTimeString(); + return; + } + + airMode = metric; + btnAqi.classList.toggle('active', metric === 'aqi'); + btnPm25.classList.toggle('active', metric === 'pm25'); + statusEl.textContent = `loading ${metric.toUpperCase()} overlay...`; + + const points = await fetchAirOverlay(metric); + if (!points.length) { + statusEl.textContent = 'no air quality data yet'; + airMode = null; + btnAqi.classList.remove('active'); + btnPm25.classList.remove('active'); + return; + } + + const gradient = metric === 'aqi' ? AQI_GRADIENT : PM25_GRADIENT; + const layer = L.heatLayer(points, { + radius: 25, + blur: 20, + maxZoom: 17, + gradient, + minOpacity: 0.3, + }); + + if (metric === 'aqi') airHeatAqi = layer; + else airHeatPm25 = layer; + + layer.addTo(map); + statusEl.textContent = `${metric.toUpperCase()} overlay · ${points.length.toLocaleString()} pts`; +} + +// ── Legend ──────────────────────────────────────────────────────────────────── +function buildAirLegend() { + const legend = L.control({ position: 'bottomright' }); + legend.onAdd = () => { + const div = L.DomUtil.create('div', 'air-legend'); + div.innerHTML = ` +
AQI / PM2.5
+
Good (0-50)
+
Moderate (51-100)
+
Unhealthy* (101-150)
+
Unhealthy (151-200)
+
Very Unhealthy (201-300)
+
Hazardous (301+)
+ `; + return div; + }; + return legend; +} + +// Call this after map init — wires up buttons and legend +function initAirOverlay() { + buildAirLegend().addTo(map); + btnAqi.addEventListener('click', () => toggleAirOverlay('aqi')); + btnPm25.addEventListener('click', () => toggleAirOverlay('pm25')); +} + + + // Init air overlay buttons + legend after map is ready + initAirOverlay(); + + +