air quality: API routes + frontend heatmap overlay (pending Cobb approval)

This commit is contained in:
Kayos 2026-03-13 07:58:16 -07:00
parent 28cacd540f
commit 33cdc3a50c
2 changed files with 690 additions and 0 deletions

145
docs/AIR-API-PATCH.py Normal file
View 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"])

View file

@ -0,0 +1,545 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AdaMaps — Verified Sign Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0d0d0d;
color: #e0e0e0;
font-family: 'Courier New', monospace;
height: 100vh;
display: flex;
flex-direction: column;
}
#header {
background: #111;
border-bottom: 1px solid #222;
padding: 10px 18px;
display: flex;
align-items: center;
gap: 12px;
z-index: 1000;
flex-wrap: wrap;
}
#header h1 {
font-size: 1.1rem;
font-weight: bold;
color: #00e5ff;
letter-spacing: 2px;
text-transform: uppercase;
}
#status {
margin-left: auto;
font-size: 0.75rem;
color: #555;
}
#status.live { color: #00e5ff; }
#status.error { color: #ff4444; }
#map {
flex: 1;
background: #111;
}
.leaflet-tile-pane { filter: brightness(0.7) invert(1) hue-rotate(180deg) saturate(0.6); }
.leaflet-popup-content-wrapper {
background: #1a1a1a;
color: #e0e0e0;
border: 1px solid #333;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.8rem;
}
.leaflet-popup-tip { background: #1a1a1a; }
.leaflet-popup-content { margin: 10px 14px; }
.popup-title { color: #00e5ff; font-weight: bold; margin-bottom: 4px; }
.popup-row { color: #aaa; }
.popup-row span { color: #e0e0e0; }
.popup-verified { color: #00ff88; font-weight: bold; }
.popup-unverified { color: #ffaa00; }
.popup-img {
width: 100%;
max-width: 240px;
border-radius: 3px;
margin-top: 8px;
border: 1px solid #333;
display: block;
cursor: pointer;
}
.popup-img:hover { border-color: #00e5ff; }
/* Marker styles */
.verified-marker {
width: 14px;
height: 14px;
background: #00ff88;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 6px rgba(0,255,136,0.5);
}
.unverified-marker {
width: 10px;
height: 10px;
background: #ffaa00;
border-radius: 50%;
border: 2px solid #fff;
opacity: 0.7;
}
.multi-obs-marker {
width: 16px;
height: 16px;
background: #00e5ff;
border-radius: 50%;
border: 2px solid #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: bold;
color: #000;
}
#count-badge {
background: #00e5ff;
color: #000;
font-size: 0.7rem;
font-weight: bold;
padding: 2px 8px;
border-radius: 12px;
letter-spacing: 1px;
}
#verified-badge {
background: #00ff88;
color: #000;
font-size: 0.7rem;
font-weight: bold;
padding: 2px 8px;
border-radius: 12px;
letter-spacing: 1px;
}
.toggle-btn {
background: #222;
border: 1px solid #444;
color: #e0e0e0;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
font-family: 'Courier New', monospace;
}
.toggle-btn:hover {
background: #333;
}
.toggle-btn.active {
background: #00e5ff;
color: #000;
border-color: #00e5ff;
}
.marker-cluster-small {
background-color: rgba(0, 229, 255, 0.4);
}
.marker-cluster-small div {
background-color: rgba(0, 229, 255, 0.8);
color: #000;
font-weight: bold;
}
.marker-cluster-medium {
background-color: rgba(0, 200, 255, 0.4);
}
.marker-cluster-medium div {
background-color: rgba(0, 200, 255, 0.8);
color: #000;
font-weight: bold;
}
.marker-cluster-large {
background-color: rgba(0, 150, 255, 0.4);
}
.air-legend {
background: #1a1a1a;
border: 1px solid #333;
padding: 8px 12px;
font-family: 'Courier New', monospace;
font-size: 0.72rem;
color: #ccc;
border-radius: 4px;
}
.legend-title { color: #00e5ff; font-weight: bold; margin-bottom: 4px; }
.legend-row { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.legend-row span {
display: inline-block; width: 12px; height: 12px;
border-radius: 2px; flex-shrink: 0;
}
.marker-cluster-large div {
background-color: rgba(0, 150, 255, 0.8);
color: #000;
font-weight: bold;
}
</style>
</head>
<body>
<div id="header">
<h1>AdaMaps</h1>
<span id="count-badge">0 signs</span>
<span id="verified-badge">0 verified</span>
<button class="toggle-btn active" id="btn-signs">Signs</button>
<button class="toggle-btn" id="btn-raw">Raw</button>
<button class="toggle-btn" id="btn-verified">Verified Only</button>
<span style="color:#333;margin:0 4px">|</span>
<button class="toggle-btn" id="btn-aqi">AQI Heat</button>
<button class="toggle-btn" id="btn-pm25">PM2.5 Heat</button>
<span id="status">loading...</span>
</div>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script>
const map = L.map('map', {
center: [33.88, -118.0],
zoom: 10,
zoomControl: true,
attributionControl: true
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
const statusEl = document.getElementById('status');
const countBadge = document.getElementById('count-badge');
const verifiedBadge = document.getElementById('verified-badge');
const btnSigns = document.getElementById('btn-signs');
const btnRaw = document.getElementById('btn-raw');
const btnVerified = document.getElementById('btn-verified');
const btnAqi = document.getElementById('btn-aqi');
const btnPm25 = document.getElementById('btn-pm25');
const markerCluster = L.markerClusterGroup({
chunkedLoading: true,
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
disableClusteringAtZoom: 16
});
map.addLayer(markerCluster);
let currentMode = 'signs';
let verifiedOnly = false;
function createVerifiedIcon() {
return L.divIcon({
className: '',
html: '<div class="verified-marker"></div>',
iconSize: [14, 14],
iconAnchor: [7, 7],
popupAnchor: [0, -10]
});
}
function createUnverifiedIcon() {
return L.divIcon({
className: '',
html: '<div class="unverified-marker"></div>',
iconSize: [10, 10],
iconAnchor: [5, 5],
popupAnchor: [0, -8]
});
}
function createMultiObsIcon(count) {
return L.divIcon({
className: '',
html: `<div class="multi-obs-marker">${count}</div>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
popupAnchor: [0, -10]
});
}
function formatTimestamp(ts) {
if (!ts) return 'unknown';
try { return new Date(ts).toLocaleString(); } catch(e) { return ts; }
}
function buildSignPopup(s) {
const verifiedClass = s.verified ? 'popup-verified' : 'popup-unverified';
const verifiedText = s.verified ? '✓ VERIFIED' : 'unverified';
return `
<div class="popup-title">${s.sign_type || 'Unknown Sign'}</div>
<div class="popup-row ${verifiedClass}">${verifiedText}</div>
<div class="popup-row">Observations: <span>${s.observations}</span></div>
<div class="popup-row">Devices: <span>${s.devices}</span></div>
<div class="popup-row">Confidence: <span>${(s.confidence * 100).toFixed(1)}%</span></div>
<div class="popup-row">Heading: <span>${s.heading || '—'}</span></div>
<div class="popup-row">First seen: <span>${formatTimestamp(s.first_seen)}</span></div>
<div class="popup-row">Last seen: <span>${formatTimestamp(s.last_seen)}</span></div>
<div class="popup-row">Coords: <span>${s.lat.toFixed(6)}, ${s.lon.toFixed(6)}</span></div>
${s.image_url ? `<img class="popup-img" src="${s.image_url}" loading="lazy" alt="detection photo" onclick="window.open('${s.image_url}','_blank')">` : ''}
`;
}
function buildDetectionPopup(d) {
return `
<div class="popup-title">${d.class_label || 'Detection'}</div>
<div class="popup-row">Device: <span>${d.device_id || '—'}</span></div>
<div class="popup-row">Time: <span>${formatTimestamp(d.ts)}</span></div>
<div class="popup-row">Confidence: <span>${d.confidence ? (d.confidence * 100).toFixed(1) + '%' : '—'}</span></div>
<div class="popup-row">Coords: <span>${d.lat?.toFixed(6)}, ${d.lon?.toFixed(6)}</span></div>
${d.image_url ? `<img class="popup-img" src="${d.image_url}" loading="lazy" alt="detection photo" onclick="window.open('${d.image_url}','_blank')">` : ''}
`;
}
async function fetchAndPlot() {
statusEl.textContent = 'fetching...';
statusEl.className = '';
try {
let url, data;
if (currentMode === 'signs') {
url = verifiedOnly
? 'https://api.adamaps.org/api/signs?verified=true&limit=5000'
: 'https://api.adamaps.org/api/signs?limit=5000';
} else {
url = 'https://api.adamaps.org/api/detections?limit=5000';
}
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) {
if (res.status === 404 && currentMode === 'signs') {
statusEl.textContent = 'Run clustering first';
statusEl.className = 'error';
return;
}
throw new Error(`HTTP ${res.status}`);
}
data = await res.json();
markerCluster.clearLayers();
const bounds = [];
let verifiedCount = 0;
if (currentMode === 'signs') {
data.forEach(s => {
if (s.lat == null || s.lon == null) return;
let icon;
if (s.verified) {
verifiedCount++;
icon = s.observations > 1 ? createMultiObsIcon(s.observations) : createVerifiedIcon();
} else {
icon = createUnverifiedIcon();
}
const marker = L.marker([s.lat, s.lon], { icon });
marker.bindPopup(buildSignPopup(s));
markerCluster.addLayer(marker);
bounds.push([s.lat, s.lon]);
});
countBadge.textContent = `${data.length.toLocaleString()} sign${data.length !== 1 ? 's' : ''}`;
verifiedBadge.textContent = `${verifiedCount} verified`;
} else {
data.forEach(d => {
const lat = d.lat ?? d.latitude;
const lng = d.lng ?? d.longitude ?? d.lon;
if (lat == null || lng == null) return;
const marker = L.marker([lat, lng], { icon: createUnverifiedIcon() });
marker.bindPopup(buildDetectionPopup(d));
markerCluster.addLayer(marker);
bounds.push([lat, lng]);
});
countBadge.textContent = `${data.length.toLocaleString()} detection${data.length !== 1 ? 's' : ''}`;
verifiedBadge.textContent = '—';
}
statusEl.textContent = `live · ${new Date().toLocaleTimeString()}`;
statusEl.className = 'live';
if (bounds.length > 0 && map.getZoom() < 8) {
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
}
} catch(err) {
statusEl.textContent = `error: ${err.message}`;
statusEl.className = 'error';
console.error('AdaMaps fetch error:', err);
}
}
// Button handlers
btnSigns.addEventListener('click', () => {
currentMode = 'signs';
verifiedOnly = false;
btnSigns.classList.add('active');
btnRaw.classList.remove('active');
btnVerified.classList.remove('active');
fetchAndPlot();
});
btnRaw.addEventListener('click', () => {
currentMode = 'raw';
verifiedOnly = false;
btnRaw.classList.add('active');
btnSigns.classList.remove('active');
btnVerified.classList.remove('active');
fetchAndPlot();
});
btnVerified.addEventListener('click', () => {
currentMode = 'signs';
verifiedOnly = true;
btnVerified.classList.add('active');
btnSigns.classList.remove('active');
btnRaw.classList.remove('active');
fetchAndPlot();
});
fetchAndPlot();
setInterval(fetchAndPlot, 60000);
// ============ AIR QUALITY OVERLAY (add to adamaps.org/index.html) ============
// Requires: leaflet-heat plugin
// Add to <head>:
// <script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
// 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 = `
<div class="legend-title">AQI / PM2.5</div>
<div class="legend-row"><span style="background:#00e400"></span> Good (0-50)</div>
<div class="legend-row"><span style="background:#ffff00"></span> Moderate (51-100)</div>
<div class="legend-row"><span style="background:#ff7e00"></span> Unhealthy* (101-150)</div>
<div class="legend-row"><span style="background:#ff0000"></span> Unhealthy (151-200)</div>
<div class="legend-row"><span style="background:#8f3f97"></span> Very Unhealthy (201-300)</div>
<div class="legend-row"><span style="background:#7e0023"></span> Hazardous (301+)</div>
`;
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();
</script>
</body>
</html>