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
7 changes: 6 additions & 1 deletion backend/scripts/simulate_zone.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,12 @@ def main() -> None:
resp = requests.post(f"{API_URL}/readings/", json=payload, headers=headers(), timeout=10)
status = resp.status_code
mag = estimate_magnitude(value)
flag = " 🚨 CRITICAL!" if mag >= 4.5 else (" ⚠️ caution" if mag >= 4.0 else "")
if mag >= 4.5:
flag = " 🚨 CRITICAL!"
elif mag >= 4.0:
flag = " ⚠️ caution"
else:
flag = ""
print(f" t+{step:>2}s value={value:>5} M≈{mag:.2f}{flag} http {status}", flush=True)
if status != 202:
print(f" API: {resp.text}", flush=True)
Expand Down
21 changes: 11 additions & 10 deletions backend/src/geo.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ def point_to_geohash(latitude: float, longitude: float, precision: int) -> str:
return "".join(out)


def _refine_axis(ranges: list[float], cd: int, mask: int) -> None:
"""Narrow ``ranges`` to the half-interval selected by one geohash bit."""
mid = (ranges[0] + ranges[1]) / 2.0
if cd & mask:
ranges[0] = mid
else:
ranges[1] = mid


def geohash_bounds(geohash: str) -> tuple[float, float, float, float]:
"""Decode a geohash into ``(lon_min, lat_min, lon_max, lat_max)``."""
lat_range = [-90.0, 90.0]
Expand All @@ -85,17 +94,9 @@ def geohash_bounds(geohash: str) -> tuple[float, float, float, float]:
cd = _BASE32.index(char)
for mask in (16, 8, 4, 2, 1):
if even:
mid = (lon_range[0] + lon_range[1]) / 2.0
if cd & mask:
lon_range[0] = mid
else:
lon_range[1] = mid
_refine_axis(lon_range, cd, mask)
else:
mid = (lat_range[0] + lat_range[1]) / 2.0
if cd & mask:
lat_range[0] = mid
else:
lat_range[1] = mid
_refine_axis(lat_range, cd, mask)
even = not even
return lon_range[0], lat_range[0], lon_range[1], lat_range[1]

Expand Down
8 changes: 5 additions & 3 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@

PING_QUERY = "SELECT 1"

ZONE_NOT_FOUND = "Zone not found"

# --- SECURE CONFIGURATION ---
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")

Expand Down Expand Up @@ -497,7 +499,7 @@ def get_zone_readings(zone_id: int, limit: int = 60, db: Session = Depends(get_d
"""
zone = db.query(models.Zone).filter(models.Zone.id == zone_id).first()
if not zone:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zone not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ZONE_NOT_FOUND)
limit = max(1, min(limit, 200))
return (
db.query(models.Reading)
Expand All @@ -519,7 +521,7 @@ def delete_zone_readings(zone_id: int, db: Session = Depends(get_db)):
"""
zone = db.query(models.Zone).filter(models.Zone.id == zone_id).first()
if not zone:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zone not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ZONE_NOT_FOUND)

sensor_ids = [
row[0]
Expand All @@ -545,7 +547,7 @@ def get_zone_alerts(zone_id: int, limit: int = 20, db: Session = Depends(get_db)
"""
zone = db.query(models.Zone).filter(models.Zone.id == zone_id).first()
if not zone:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zone not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ZONE_NOT_FOUND)
limit = max(1, min(limit, 100))
return (
db.query(models.Alert)
Expand Down
74 changes: 49 additions & 25 deletions backend/src/timescale.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,30 +22,18 @@
TSDB_RETENTION_DAYS = os.getenv("TSDB_RETENTION_DAYS", "180")


def apply_timescale(db: Session) -> dict:
"""Apply TimescaleDB DDL where possible. Returns a per-step report dict.

Steps are isolated: a failure in any step never blocks the others nor the
application startup.
"""
report = {
"timescaledb": False,
"hypertable": False,
"aggregate": False,
"retention": False,
}

# 1. Extension ---------------------------------------------------------
def _enable_extension(db: Session) -> bool:
try:
db.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb"))
db.commit()
report["timescaledb"] = True
return True
except Exception as e:
db.rollback()
print(f"⚠️ TimescaleDB extension unavailable: {e}", flush=True)
return report
return False

# 2. Hypertable --------------------------------------------------------

def _create_hypertable(db: Session) -> bool:
try:
already_hypertable = db.execute(
text(
Expand All @@ -61,13 +49,15 @@ def apply_timescale(db: Session) -> dict:
)
)
db.commit()
report["hypertable"] = True
print("✅ readings is a TimescaleDB hypertable (chunked on recorded_at).", flush=True)
return True
except Exception as e:
db.rollback()
print(f"⚠️ Hypertable creation skipped: {e}", flush=True)
return False

# 3. Continuous aggregate (per-sensor minute rollups) -------------------

def _create_continuous_aggregate(db: Session) -> bool:
try:
db.execute(
text(
Expand All @@ -91,13 +81,15 @@ def apply_timescale(db: Session) -> dict:
db.commit()
except Exception:
db.rollback() # policy already present or not yet refreshable
report["aggregate"] = True
print("✅ Continuous aggregate readings_minute created.", flush=True)
return True
except Exception as e:
db.rollback()
print(f"⚠️ Continuous aggregate skipped: {e}", flush=True)
return False

# 4. Compression + retention -------------------------------------------

def _apply_compression(db: Session) -> None:
try:
# Newer TimescaleDB (2.13+/3.x) requires columnstore on the hypertable
# before a columnstore compression policy can be added.
Expand All @@ -114,6 +106,9 @@ def apply_timescale(db: Session) -> dict:
db.rollback()
if "already exists" not in str(e).lower():
print(f"⚠️ Compression policy skipped: {e}", flush=True)


def _apply_retention(db: Session) -> bool:
try:
db.execute(
text(
Expand All @@ -122,13 +117,42 @@ def apply_timescale(db: Session) -> dict:
)
)
db.commit()
report["retention"] = True
return True
except Exception as e:
db.rollback()
if "already exists" in str(e).lower():
report["retention"] = True
else:
print(f"⚠️ Retention policy skipped: {e}", flush=True)
return True
print(f"⚠️ Retention policy skipped: {e}", flush=True)
return False


def apply_timescale(db: Session) -> dict:
"""Apply TimescaleDB DDL where possible. Returns a per-step report dict.

Steps are isolated: a failure in any step never blocks the others nor the
application startup.
"""
report = {
"timescaledb": False,
"hypertable": False,
"aggregate": False,
"retention": False,
}

# 1. Extension ---------------------------------------------------------
if not _enable_extension(db):
return report
report["timescaledb"] = True

# 2. Hypertable --------------------------------------------------------
report["hypertable"] = _create_hypertable(db)

# 3. Continuous aggregate (per-sensor minute rollups) -------------------
report["aggregate"] = _create_continuous_aggregate(db)

# 4. Compression + retention -------------------------------------------
_apply_compression(db)
report["retention"] = _apply_retention(db)
if report["retention"]:
print(f"✅ Retention policy set to {TSDB_RETENTION_DAYS} days.", flush=True)

Expand Down
70 changes: 40 additions & 30 deletions backend/src/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,44 @@ def enqueue_ai_report(db: Session, event: dict, alert_id: int, zone_id: int, mag
redis_sync.lpush(AI_REPORT_QUEUE, json.dumps(ai_payload))
print(f"🤖 AI Report enqueued (report_id={report.id})", flush=True)

def _parse_batch(batch: list) -> list:
"""Decode raw stream entries; park malformed payloads on the DLQ."""
pending = []
for message_id, payload in batch:
try:
event = json.loads(payload)
except Exception:
print("❌ Malformed payload -> DLQ.", flush=True)
try:
move_to_dlq(redis_sync, message_id, payload, reason="malformed_json")
except Exception as e:
print(f"❌ DLQ write failed: {e}", flush=True)
continue
pending.append((message_id, event, payload))
return pending


def _process_pending(pending: list, db: Session) -> None:
"""Persist a parsed batch in one transaction and acknowledge the stream."""
try:
process_batch([event for _, event, _ in pending], db)
ack(redis_sync, [message_id for message_id, _, _ in pending])
for _, event, _ in pending:
print(
f"✅ Processed sensor {event.get('sensor_id')} -> {event.get('value')} "
f"(Mag: {estimate_magnitude(event.get('value', 0))})",
flush=True,
)
except Exception as e:
print(f"❌ Batch DB Error: {e}. Moving batch to DLQ.", flush=True)
db.rollback()
for message_id, _, payload in pending:
try:
move_to_dlq(redis_sync, message_id, payload, reason=f"process_error: {e}")
except Exception as dlq_err:
print(f"❌ DLQ write failed: {dlq_err}", flush=True)


def run_worker():
consumer = f"{CONSUMER_PREFIX}-{socket.gethostname()}-{os.getpid()}"
print(f"👷 Worker started. stream='{READINGS_STREAM}' group='{READINGS_GROUP}' consumer='{consumer}'")
Expand All @@ -195,39 +233,11 @@ def run_worker():
if not batch:
continue

pending = []
for message_id, payload in batch:
try:
event = json.loads(payload)
except Exception:
print("❌ Malformed payload -> DLQ.", flush=True)
try:
move_to_dlq(redis_sync, message_id, payload, reason="malformed_json")
except Exception as e:
print(f"❌ DLQ write failed: {e}", flush=True)
continue
pending.append((message_id, event, payload))

pending = _parse_batch(batch)
if not pending:
continue

try:
process_batch([event for _, event, _ in pending], db)
ack(redis_sync, [message_id for message_id, _, _ in pending])
for _, event, _ in pending:
print(
f"✅ Processed sensor {event.get('sensor_id')} -> {event.get('value')} "
f"(Mag: {estimate_magnitude(event.get('value', 0))})",
flush=True,
)
except Exception as e:
print(f"❌ Batch DB Error: {e}. Moving batch to DLQ.", flush=True)
db.rollback()
for message_id, _, payload in pending:
try:
move_to_dlq(redis_sync, message_id, payload, reason=f"process_error: {e}")
except Exception as dlq_err:
print(f"❌ DLQ write failed: {dlq_err}", flush=True)
_process_pending(pending, db)

except Exception as e:
print(f"❌ Redis Connection Error: {e}", flush=True)
Expand Down
4 changes: 2 additions & 2 deletions firmware/src/GnssModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

#ifdef GNSS_ENABLED

namespace {
namespace quakeguard_gnss {

float scaleLat(double lat) {
return static_cast<float>(lat);
Expand All @@ -12,7 +12,7 @@ float scaleLon(double lon) {
return static_cast<float>(lon);
}

} // namespace
} // namespace quakeguard_gnss

GnssModule& gnss() {
static GnssModule instance;
Expand Down
2 changes: 1 addition & 1 deletion firmware/src/GnssModule.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
#define GPS_SERIAL_BAUD 9600
#endif

namespace {
namespace quakeguard_gnss {
constexpr const char* GNSS_NVS_NAMESPACE = "quake-gnss";
constexpr const char* GNSS_NVS_LAT = "lat";
constexpr const char* GNSS_NVS_LON = "lon";
Expand Down
Loading
Loading