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 @@ + + +
+ + +