diff --git a/backend/src/routes/anomaly.routes.ts b/backend/src/routes/anomaly.routes.ts new file mode 100644 index 0000000..245c256 --- /dev/null +++ b/backend/src/routes/anomaly.routes.ts @@ -0,0 +1,105 @@ +/** + * AI-powered carbon emission anomaly detection. + * + * Tier-1 implementation is a pure statistical rule (rolling z-score + sudden + * percentage spike). Stateless — accepts a series of CO2 readings in the + * request body and returns alerts. No DB, no model training, no Python. + * + * Swapping in a real ML model later is a one-line change inside `detect()`: + * the route shape is intentionally identical to what an ML microservice + * (e.g. FastAPI + Isolation Forest) would expose. + */ +import { Router } from 'express'; + +export const anomalyRouter = Router(); + +interface EmissionReading { + timestamp: string; + co2: number; +} + +interface AnomalyAlert { + timestamp: string; + value: number; + severity: 'low' | 'medium' | 'high'; + confidence: number; + reason: string; +} + +function mean(values: number[]): number { + return values.reduce((sum, v) => sum + v, 0) / values.length; +} + +function standardDeviation(values: number[]): number { + const avg = mean(values); + const variance = + values.reduce((sum, v) => sum + (v - avg) ** 2, 0) / values.length; + return Math.sqrt(variance); +} + +function getSeverity( + zScore: number, + percentageIncrease: number, +): 'low' | 'medium' | 'high' { + if (zScore >= 3.5 || percentageIncrease >= 80) return 'high'; + if (zScore >= 2.5 || percentageIncrease >= 40) return 'medium'; + return 'low'; +} + +anomalyRouter.post('/detect', (req, res) => { + const readings = req.body?.readings as EmissionReading[] | undefined; + + if (!Array.isArray(readings) || readings.length < 3) { + return res.status(400).json({ + hasAnomaly: false, + alerts: [], + summary: 'At least 3 CO2 readings are required for anomaly detection.', + }); + } + + const alerts: AnomalyAlert[] = []; + const windowSize = 5; + + for (let i = 1; i < readings.length; i++) { + const current = readings[i]; + const previous = readings[i - 1]; + if (!current || !previous) continue; + + const start = Math.max(0, i - windowSize); + const historical = readings.slice(start, i).map((r) => r.co2); + + const avg = mean(historical); + const stdDev = standardDeviation(historical) || 1; + const zScore = (current.co2 - avg) / stdDev; + const percentageIncrease = + previous.co2 === 0 + ? 0 + : ((current.co2 - previous.co2) / previous.co2) * 100; + + const isZScoreAnomaly = zScore > 2.5; + const isSuddenSpike = percentageIncrease >= 40; + + if (isZScoreAnomaly || isSuddenSpike) { + alerts.push({ + timestamp: current.timestamp, + value: current.co2, + severity: getSeverity(zScore, percentageIncrease), + confidence: Number( + Math.min(0.99, Math.max(0.7, zScore / 4)).toFixed(2), + ), + reason: isSuddenSpike + ? `CO2 increased by ${percentageIncrease.toFixed(1)}% compared to previous reading` + : `CO2 z-score ${zScore.toFixed(2)} exceeded anomaly threshold of 2.5`, + }); + } + } + + return res.json({ + hasAnomaly: alerts.length > 0, + alerts, + summary: + alerts.length > 0 + ? `${alerts.length} sudden carbon emission spike${alerts.length > 1 ? 's' : ''} detected.` + : 'No abnormal emission spike detected.', + }); +}); diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 836b09f..62026f0 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -3,6 +3,7 @@ import { healthRouter } from './health.routes'; import { authRouter } from './auth.routes'; import { aqiRouter } from './aqi.routes'; import { reportRouter } from './report.routes'; +import { anomalyRouter } from './anomaly.routes'; import publicHeatmapRouter from './publicHeatmap'; /** @@ -15,4 +16,5 @@ apiRouter.use('/health', healthRouter); apiRouter.use('/auth', authRouter); apiRouter.use('/aqi', aqiRouter); apiRouter.use('/reports', reportRouter); +apiRouter.use('/anomaly', anomalyRouter); apiRouter.use('/public', publicHeatmapRouter); diff --git a/src/App.tsx b/src/App.tsx index 62c4da4..f08c884 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import CapabilitiesModal from "./components/CapabilitiesModal"; import LiveAirQualityPanel from "./components/LiveAirQualityPanel"; import { registerUser, loginUser, logoutUser, bootstrapUser } from "./lib/auth"; import { listReports, generateReport as generateReportApi, deleteReport as deleteReportApi, downloadReport, uiTypeToBackend, formatBytes } from "./lib/reports"; +import { detectAnomaly } from "./lib/anomaly"; // ── STORAGE ────────────────────────────────────────────────────────────────── const S = { @@ -719,6 +720,32 @@ const Dashboard = ({ emissions, setPage }) => { { lvl: 'INFO', msg: 'Satellite batch synchronized', t: '34 min ago', bc: 'b-blue' }, { lvl: 'OK', msg: 'Monthly compliance report dispatched', t: '1 hr ago', bc: 'b-green' }, ]; + + // Anomaly detection — calls /api/v1/anomaly/detect with a sample series. + // (When real emission submission is wired, the readings will come from the DB.) + const mockEmissionReadings = [ + { timestamp: '2026-05-29T10:00:00Z', co2: 120 }, + { timestamp: '2026-05-29T11:00:00Z', co2: 125 }, + { timestamp: '2026-05-29T12:00:00Z', co2: 132 }, + { timestamp: '2026-05-29T13:00:00Z', co2: 128 }, + { timestamp: '2026-05-29T14:00:00Z', co2: 260 }, + ]; + const [anomalyResult, setAnomalyResult] = useState(null); + const [anomalyLoading, setAnomalyLoading] = useState(false); + const [anomalyError, setAnomalyError] = useState(''); + + const runAnomalyDetection = async () => { + setAnomalyLoading(true); + setAnomalyError(''); + try { + const data = await detectAnomaly(mockEmissionReadings); + setAnomalyResult(data); + } catch (e) { + setAnomalyError(e?.message || 'Unable to reach anomaly engine. Is the backend running?'); + } finally { + setAnomalyLoading(false); + } + }; const caps = [ { n: 'Real-Time Monitoring', d: '30-sec sensor refresh · 240 stations' }, @@ -922,6 +949,41 @@ const Dashboard = ({ emissions, setPage }) => { > {cap.d} + + {cap.n === 'AI Anomaly Detection' && ( +
+ + {anomalyError && ( +
+ {anomalyError} +
+ )} + {anomalyResult?.hasAnomaly ? ( +
+
CARBON SPIKE DETECTED
+ {anomalyResult.alerts.map((alert, idx) => ( +
+
CO2 VALUE: {alert.value}
+
SEVERITY: {String(alert.severity).toUpperCase()}
+
CONFIDENCE: {(alert.confidence * 100).toFixed(0)}%
+
REASON: {alert.reason}
+
+ ))} +
+ ) : anomalyResult ? ( +
+ NO ABNORMAL EMISSION SPIKE DETECTED +
+ ) : null} +
+ )} ))} diff --git a/src/lib/anomaly.ts b/src/lib/anomaly.ts new file mode 100644 index 0000000..b97c921 --- /dev/null +++ b/src/lib/anomaly.ts @@ -0,0 +1,34 @@ +import { api } from './api'; + +export interface EmissionReading { + timestamp: string; + co2: number; +} + +export interface AnomalyAlert { + timestamp: string; + value: number; + severity: 'low' | 'medium' | 'high'; + confidence: number; + reason: string; +} + +export interface AnomalyResponse { + hasAnomaly: boolean; + alerts: AnomalyAlert[]; + summary: string; +} + +/** + * Calls the backend anomaly detection endpoint. + * Uses the shared `api()` helper so it picks up VITE_API_URL automatically + * (no hard-coded localhost ports). + */ +export async function detectAnomaly( + readings: EmissionReading[], +): Promise { + return api('/anomaly/detect', { + method: 'POST', + body: { readings }, + }); +}