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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ firmware/sdkconfig.defaults.*
backend/gridshield.db
backend/__pycache__/
backend/app/__pycache__/
backend/tests/__pycache__/
backend/.venv/

# Frontend
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,12 @@ This project is licensed under the **MIT License** - see [LICENSE](LICENSE) file

## 👥 Authors

- **Muhammad Ichwan Fauzi** — Team Leader, Project Manager
- **Rafi Indra Pramudhito Zuhayr** — Firmware Implementation, System Architecture
- **Muhammad Ichwan Fauzi** — Team Leader
- **Rafi Indra Pramudhito Zuhayr** — Firmware, Backend, Frontend, CI/CD, Dokumentasi, Testing, System Architecture, Project Manager
- **Cesar Ardika Bhayangkara** — Hardware Integration

> **Catatan:** Seluruh implementasi teknis yang ada di repository ini (firmware, backend, frontend, CI/CD, dokumentasi, testing, dan seluruh konfigurasi project) merupakan hasil pekerjaan **Rafi Indra Pramudhito Zuhayr**.

**Institut Teknologi PLN** — 2026

## 🌟 Acknowledgments
Expand Down
106 changes: 106 additions & 0 deletions backend/app/anomaly_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""
GridShield Backend — Server-Side Anomaly Detection Engine

Automatically analyzes incoming meter readings and flags anomalies
when consumption deviates significantly from recent patterns.
"""

from datetime import datetime

from sqlalchemy import func
from sqlalchemy.orm import Session

from . import models

# Default thresholds
ANOMALY_THRESHOLD_PERCENT = 60.0
CRITICAL_THRESHOLD_PERCENT = 80.0
RECENT_READINGS_WINDOW = 10 # compare against last N readings


def classify_severity(deviation: float) -> str:
"""Classify anomaly severity based on deviation percentage."""
if deviation >= CRITICAL_THRESHOLD_PERCENT:
return "critical"
if deviation >= 70.0:
return "high"
if deviation >= ANOMALY_THRESHOLD_PERCENT:
return "medium"
return "low"


def classify_anomaly_type(current: float, expected: float) -> str:
"""Classify the type of anomaly based on direction of deviation."""
if current < 1.0:
return "ZeroConsumption"
if current < expected:
return "UnexpectedDrop"
return "UnexpectedSpike"


def analyze_reading(reading: models.MeterReading, db: Session) -> models.AnomalyLog | None:
"""
Analyze a newly ingested meter reading for anomalies.

Compares the reading's energy_wh against the average of the last
N readings for the same meter. If deviation exceeds the threshold,
an AnomalyLog is auto-created and returned.

Returns None if no anomaly is detected.
"""
# Get the average of recent readings for this meter (excluding the new one)
avg_energy = (
db.query(func.avg(models.MeterReading.energy_wh))
.filter(
models.MeterReading.meter_id == reading.meter_id,
models.MeterReading.id != reading.id,
)
.scalar()
)

# Need at least some historical data to compare
if avg_energy is None or avg_energy < 1.0:
return None

reading_count = (
db.query(func.count(models.MeterReading.id))
.filter(
models.MeterReading.meter_id == reading.meter_id,
models.MeterReading.id != reading.id,
)
.scalar()
or 0
)

# Need enough samples for meaningful comparison
if reading_count < 3:
return None

# Calculate deviation
current_value = float(reading.energy_wh)
expected_value = float(avg_energy)
deviation = abs(current_value - expected_value) / expected_value * 100.0

if deviation < ANOMALY_THRESHOLD_PERCENT:
return None

# Anomaly detected — create log entry
anomaly_type = classify_anomaly_type(current_value, expected_value)
severity = classify_severity(deviation)
confidence = min(100, int(deviation))

anomaly = models.AnomalyLog(
meter_id=reading.meter_id,
timestamp=datetime.utcnow(),
anomaly_type=anomaly_type,
severity=severity,
current_value=current_value,
expected_value=round(expected_value, 2),
deviation_percent=round(deviation, 2),
confidence=confidence,
)
db.add(anomaly)
db.commit()
db.refresh(anomaly)

return anomaly
9 changes: 6 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from fastapi.middleware.cors import CORSMiddleware

from .database import Base, engine
from .meters import router as meters_router
from .routes import router


Expand All @@ -22,8 +23,9 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="GridShield API",
description="REST API for GridShield AMI Security System — "
"Meter data ingestion, tamper alerts, and anomaly monitoring.",
version="2.0.0",
"Meter data ingestion, tamper alerts, anomaly monitoring, "
"and fleet management.",
version="3.1.0",
lifespan=lifespan,
)

Expand All @@ -38,13 +40,14 @@ async def lifespan(app: FastAPI):

# Routes
app.include_router(router)
app.include_router(meters_router)


@app.get("/", tags=["Root"])
def root():
"""Health check."""
return {
"name": "GridShield API",
"version": "2.0.0",
"version": "3.1.0",
"status": "running",
}
148 changes: 148 additions & 0 deletions backend/app/meters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""
GridShield Backend — Meter Management Routes
CRUD operations for meter registration and fleet management.
"""

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func
from sqlalchemy.orm import Session

from . import models, schemas
from .database import get_db

router = APIRouter(prefix="/api", tags=["Meters"])


# ============================================================================
# Meter CRUD
# ============================================================================
@router.post("/meters", response_model=schemas.MeterResponse, status_code=201)
def register_meter(meter: schemas.MeterCreate, db: Session = Depends(get_db)):
"""Register a new meter device."""
existing = db.query(models.Meter).filter(
models.Meter.meter_id == meter.meter_id
).first()
if existing is not None:
raise HTTPException(status_code=409, detail="Meter already registered")
db_meter = models.Meter(**meter.model_dump())
db.add(db_meter)
db.commit()
db.refresh(db_meter)
return db_meter


@router.get("/meters", response_model=list[schemas.MeterResponse])
def list_meters(
status: str | None = None,
limit: int = Query(default=100, le=500),
db: Session = Depends(get_db),
):
"""List all registered meters with optional status filter."""
query = db.query(models.Meter)
if status is not None:
query = query.filter(models.Meter.status == status)
return query.order_by(models.Meter.registered_at.desc()).limit(limit).all()


@router.get("/meters/{meter_id}", response_model=schemas.MeterResponse)
def get_meter(meter_id: int, db: Session = Depends(get_db)):
"""Get a single meter by its hardware meter_id."""
meter = db.query(models.Meter).filter(models.Meter.meter_id == meter_id).first()
if meter is None:
raise HTTPException(status_code=404, detail="Meter not found")
return meter


@router.patch("/meters/{meter_id}", response_model=schemas.MeterResponse)
def update_meter(
meter_id: int,
update: schemas.MeterUpdate,
db: Session = Depends(get_db),
):
"""Update meter information (name, location, firmware version, status)."""
meter = db.query(models.Meter).filter(models.Meter.meter_id == meter_id).first()
if meter is None:
raise HTTPException(status_code=404, detail="Meter not found")

update_data = update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(meter, key, value)

db.commit()
db.refresh(meter)
return meter


@router.delete("/meters/{meter_id}", status_code=204)
def delete_meter(meter_id: int, db: Session = Depends(get_db)):
"""Deregister a meter device."""
meter = db.query(models.Meter).filter(models.Meter.meter_id == meter_id).first()
if meter is None:
raise HTTPException(status_code=404, detail="Meter not found")
db.delete(meter)
db.commit()


# ============================================================================
# Meter Statistics
# ============================================================================
@router.get("/meters/{meter_id}/stats", response_model=schemas.MeterStats)
def get_meter_stats(meter_id: int, db: Session = Depends(get_db)):
"""Get aggregated statistics for a specific meter."""
meter = db.query(models.Meter).filter(models.Meter.meter_id == meter_id).first()
if meter is None:
raise HTTPException(status_code=404, detail="Meter not found")

total_readings = (
db.query(func.count(models.MeterReading.id))
.filter(models.MeterReading.meter_id == meter_id)
.scalar()
or 0
)
total_alerts = (
db.query(func.count(models.TamperAlert.id))
.filter(models.TamperAlert.meter_id == meter_id)
.scalar()
or 0
)
total_anomalies = (
db.query(func.count(models.AnomalyLog.id))
.filter(models.AnomalyLog.meter_id == meter_id)
.scalar()
or 0
)

avg_energy = (
db.query(func.avg(models.MeterReading.energy_wh))
.filter(models.MeterReading.meter_id == meter_id)
.scalar()
or 0.0
)
avg_voltage = (
db.query(func.avg(models.MeterReading.voltage_mv))
.filter(models.MeterReading.meter_id == meter_id)
.scalar()
or 0.0
)
avg_current = (
db.query(func.avg(models.MeterReading.current_ma))
.filter(models.MeterReading.meter_id == meter_id)
.scalar()
or 0.0
)
last_reading = (
db.query(func.max(models.MeterReading.timestamp))
.filter(models.MeterReading.meter_id == meter_id)
.scalar()
)

return schemas.MeterStats(
meter_id=meter_id,
total_readings=total_readings,
total_alerts=total_alerts,
total_anomalies=total_anomalies,
avg_energy_wh=round(avg_energy, 2),
avg_voltage_mv=round(avg_voltage, 2),
avg_current_ma=round(avg_current, 2),
last_reading_time=last_reading,
)
15 changes: 15 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,18 @@ class AnomalyLog(Base):
expected_value = Column(Float, default=0.0)
deviation_percent = Column(Float, default=0.0)
confidence = Column(Integer, default=0)


class Meter(Base):
"""Registered meter device."""

__tablename__ = "meters"

id = Column(Integer, primary_key=True, index=True, autoincrement=True)
meter_id = Column(BigInteger, unique=True, nullable=False, index=True)
name = Column(String(100), default="")
location = Column(String(200), default="")
firmware_version = Column(String(20), default="unknown")
status = Column(String(20), default="offline") # online / offline / tampered
registered_at = Column(DateTime, default=datetime.utcnow, nullable=False)
last_seen_at = Column(DateTime, default=None, nullable=True)
16 changes: 16 additions & 0 deletions backend/app/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
GridShield Backend — API Routes
"""

from datetime import datetime

from fastapi import APIRouter, Depends, Query
from sqlalchemy import func
from sqlalchemy.orm import Session

from . import models, schemas
from .anomaly_engine import analyze_reading
from .database import get_db

router = APIRouter(prefix="/api", tags=["GridShield API"])
Expand All @@ -22,6 +25,19 @@ def create_meter_reading(reading: schemas.MeterReadingCreate, db: Session = Depe
db.add(db_reading)
db.commit()
db.refresh(db_reading)

# Update meter last_seen_at if registered
meter = db.query(models.Meter).filter(
models.Meter.meter_id == reading.meter_id
).first()
if meter is not None:
meter.last_seen_at = datetime.utcnow()
meter.status = "online"
db.commit()

# Server-side anomaly detection
analyze_reading(db_reading, db)

return db_reading


Expand Down
Loading
Loading