Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions backend/src/routes/anomaly.routes.ts
Original file line number Diff line number Diff line change
@@ -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.',
});
});
2 changes: 2 additions & 0 deletions backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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);
62 changes: 62 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -922,6 +949,41 @@ const Dashboard = ({ emissions, setPage }) => {
>
{cap.d}
</div>

{cap.n === 'AI Anomaly Detection' && (
<div style={{ marginTop: 10 }}>
<button
className="btn btn-xs"
onClick={(e) => { e.stopPropagation(); runAnomalyDetection(); }}
disabled={anomalyLoading}
style={{ width: '100%', fontSize: 9, letterSpacing: '0.08em', opacity: anomalyLoading ? 0.7 : 1 }}
>
{anomalyLoading ? 'SCANNING...' : 'RUN SPIKE SCAN'}
</button>
{anomalyError && (
<div style={{ marginTop: 8, padding: '7px 8px', border: '1px solid var(--red)', background: 'var(--redx)', color: 'var(--red)', fontFamily: 'Special Elite', fontSize: 8, lineHeight: 1.5 }}>
{anomalyError}
</div>
)}
{anomalyResult?.hasAnomaly ? (
<div style={{ marginTop: 8, padding: '8px 9px', border: '1px solid var(--red)', background: 'var(--redx)', fontFamily: 'Special Elite', fontSize: 8, lineHeight: 1.55 }}>
<div style={{ color: 'var(--red)', fontWeight: 700, letterSpacing: '0.08em', marginBottom: 5 }}>CARBON SPIKE DETECTED</div>
{anomalyResult.alerts.map((alert, idx) => (
<div key={idx} style={{ borderTop: idx ? '1px dashed var(--ink5)' : 'none', paddingTop: idx ? 6 : 0, marginTop: idx ? 6 : 0 }}>
<div>CO2 VALUE: {alert.value}</div>
<div>SEVERITY: {String(alert.severity).toUpperCase()}</div>
<div>CONFIDENCE: {(alert.confidence * 100).toFixed(0)}%</div>
<div>REASON: {alert.reason}</div>
</div>
))}
</div>
) : anomalyResult ? (
<div style={{ marginTop: 8, padding: '8px 9px', border: '1px solid var(--green)', background: 'var(--greenx)', color: 'var(--green)', fontFamily: 'Special Elite', fontSize: 8, lineHeight: 1.5 }}>
NO ABNORMAL EMISSION SPIKE DETECTED
</div>
) : null}
</div>
)}
</div>
))}
</div>
Expand Down
34 changes: 34 additions & 0 deletions src/lib/anomaly.ts
Original file line number Diff line number Diff line change
@@ -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<AnomalyResponse> {
return api<AnomalyResponse>('/anomaly/detect', {
method: 'POST',
body: { readings },
});
}