feat: dataset export endpoint + SQLite dev-mode (geometry decoupling)

Dataset export (source data for model training):
- GET /api/v1/export?format=jsonl|csv|geojson with category/data_source
  filters; streams a labeled dataset (signal params + identified device +
  routed category) suitable for training a Sub-GHz classifier.

SQLite dev-mode (corrects FABLE brief: SQLite was NOT a drop-in swap):
- models.py made dialect-aware — JSONB->JSON, ARRAY(Text)->JSON, TSVECTOR
  ->Text via .with_variant(); PostGIS Geometry column + GiST index only
  defined when not on SQLite (lat/lon + haversine bbox used instead).
- config/database.py honors DATABASE_URL / a full-URL override and builds
  a SQLite engine (check_same_thread=False, no server pool) when the URL
  is sqlite; PostgreSQL keeps pooling + UTC session.

Verified: create_all + Capture/CaptureMatch/Device CRUD + JSON round-trip
+ bbox query all work on sqlite; postgres mode still defines geom + gist
index; 52/52 unit tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-18 13:15:46 -07:00
parent 78c28fe43f
commit b1a3e2a11d
3 changed files with 171 additions and 30 deletions
+30 -5
View File
@@ -24,7 +24,8 @@ class DatabaseConfig:
password: str = "giglez_secure_password_2026", password: str = "giglez_secure_password_2026",
pool_size: int = 10, pool_size: int = 10,
max_overflow: int = 20, max_overflow: int = 20,
echo: bool = False echo: bool = False,
url: Optional[str] = None
): ):
""" """
Initialize database configuration Initialize database configuration
@@ -38,6 +39,9 @@ class DatabaseConfig:
pool_size: Connection pool size (Wigle pattern: moderate pooling) pool_size: Connection pool size (Wigle pattern: moderate pooling)
max_overflow: Max overflow connections max_overflow: Max overflow connections
echo: Echo SQL queries (debug mode) echo: Echo SQL queries (debug mode)
url: Full SQLAlchemy URL override (e.g. sqlite:///./giglez.db).
When set, it takes precedence over the host/port/user fields.
Falls back to the DATABASE_URL env var.
""" """
self.host = host self.host = host
self.port = port self.port = port
@@ -47,29 +51,50 @@ class DatabaseConfig:
self.pool_size = pool_size self.pool_size = pool_size
self.max_overflow = max_overflow self.max_overflow = max_overflow
self.echo = echo self.echo = echo
self.url = url or os.getenv("DATABASE_URL")
self._engine: Optional = None self._engine: Optional = None
self._session_factory: Optional[sessionmaker] = None self._session_factory: Optional[sessionmaker] = None
@property
def is_sqlite(self) -> bool:
return bool(self.url) and self.url.startswith("sqlite")
@property @property
def connection_string(self) -> str: def connection_string(self) -> str:
"""Generate PostgreSQL connection string""" """SQLAlchemy connection string (URL override wins, else PostgreSQL)"""
if self.url:
return self.url
return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}" return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}"
@property @property
def connection_string_safe(self) -> str: def connection_string_safe(self) -> str:
"""Generate connection string without password (for logging)""" """Connection string without password (for logging)"""
if self.url:
# sqlite URLs carry no password; postgres URLs would — mask them
if self.is_sqlite:
return self.url
return "postgresql://****"
return f"postgresql://{self.user}:****@{self.host}:{self.port}/{self.database}" return f"postgresql://{self.user}:****@{self.host}:{self.port}/{self.database}"
def get_engine(self): def get_engine(self):
""" """
Get or create SQLAlchemy engine Get or create SQLAlchemy engine.
Uses connection pooling for performance (Wigle pattern) PostgreSQL uses connection pooling + UTC session (Wigle pattern).
SQLite (dev/MVP) uses a simple engine with cross-thread access enabled.
""" """
if self._engine is None: if self._engine is None:
logger.info(f"Creating database engine: {self.connection_string_safe}") logger.info(f"Creating database engine: {self.connection_string_safe}")
if self.is_sqlite:
# SQLite dev mode: no server-side pool, allow use across threads
self._engine = create_engine(
self.connection_string,
echo=self.echo,
connect_args={"check_same_thread": False},
)
else:
self._engine = create_engine( self._engine = create_engine(
self.connection_string, self.connection_string,
poolclass=QueuePool, poolclass=QueuePool,
+92 -2
View File
@@ -4,15 +4,17 @@ GigLez FastAPI Application - Simplified Version
Runs without database requirement for testing web interface Runs without database requirement for testing web interface
""" """
import csv
import io
import json import json
import sys import sys
from pathlib import Path from pathlib import Path
from typing import List from typing import List, Optional
from datetime import datetime from datetime import datetime
from fastapi import FastAPI, Request, UploadFile, File, Form from fastapi import FastAPI, Request, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -240,6 +242,94 @@ async def get_stats():
} }
# =============================================================================
# DATASET EXPORT (for model training)
# =============================================================================
# Columns exported for training datasets. Order is stable across formats.
EXPORT_FIELDS = [
"id", "filename", "frequency", "protocol", "preset",
"device_name", "device_category", "match_confidence", "match_method",
"latitude", "longitude", "gps_source", "timestamp",
"data_source", "session_id",
]
def _iter_export_rows(category: Optional[str], data_source: Optional[str]):
"""Yield capture dicts filtered by category / data_source."""
for cap in captures_storage:
if category and cap.get("device_category") != category:
continue
if data_source and cap.get("data_source") != data_source:
continue
yield cap
@app.get("/api/v1/export")
async def export_dataset(
format: str = "jsonl",
category: Optional[str] = None,
data_source: Optional[str] = None,
):
"""
Export captures as a labeled dataset for model training.
Query params:
- format: jsonl (default) | csv | geojson
- category: filter to one device category (e.g. "Weather Sensor")
- data_source: filter to one data source (e.g. "production")
Each row carries the signal parameters + the auto-identified device
label + category, suitable as training data for a Sub-GHz classifier.
Note: this JSON-backed store does not retain raw pulse timing arrays;
for RAW-pulse feature training, use the DB-backed path (Capture.raw_data).
"""
fmt = format.lower()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
if fmt == "jsonl":
def gen():
for cap in _iter_export_rows(category, data_source):
yield json.dumps({k: cap.get(k) for k in EXPORT_FIELDS}) + "\n"
return StreamingResponse(
gen(),
media_type="application/x-ndjson",
headers={"Content-Disposition": f'attachment; filename="giglez_dataset_{ts}.jsonl"'},
)
if fmt == "csv":
def gen():
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=EXPORT_FIELDS, extrasaction="ignore")
writer.writeheader()
yield buf.getvalue()
for cap in _iter_export_rows(category, data_source):
buf.seek(0); buf.truncate(0)
writer.writerow({k: cap.get(k) for k in EXPORT_FIELDS})
yield buf.getvalue()
return StreamingResponse(
gen(),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="giglez_dataset_{ts}.csv"'},
)
if fmt == "geojson":
features = []
for cap in _iter_export_rows(category, data_source):
lat, lon = cap.get("latitude"), cap.get("longitude")
if lat is None or lon is None:
continue
features.append({
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {k: cap.get(k) for k in EXPORT_FIELDS if k not in ("latitude", "longitude")},
})
return {"type": "FeatureCollection", "features": features}
return {"error": f"Unsupported format '{format}'. Use jsonl, csv, or geojson."}
@app.delete("/api/v1/admin/cleanup") @app.delete("/api/v1/admin/cleanup")
async def cleanup_test_data(data_source: str = "test"): async def cleanup_test_data(data_source: str = "test"):
""" """
+37 -11
View File
@@ -5,17 +5,38 @@ Database models matching the PostgreSQL + PostGIS schema
Based on Wigle wardriving patterns adapted for IoT RF device mapping Based on Wigle wardriving patterns adapted for IoT RF device mapping
""" """
import os
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List
from sqlalchemy import ( from sqlalchemy import (
Column, String, Integer, Float, DateTime, Text, Boolean, Column, String, Integer, Float, DateTime, Text, Boolean,
DECIMAL, ARRAY, ForeignKey, CheckConstraint, UniqueConstraint, DECIMAL, ARRAY, ForeignKey, CheckConstraint, UniqueConstraint,
Index, LargeBinary Index, LargeBinary, JSON
) )
from sqlalchemy.orm import declarative_base, relationship from sqlalchemy.orm import declarative_base, relationship
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from geoalchemy2 import Geometry
from geoalchemy2.functions import ST_SetSRID, ST_MakePoint # =============================================================================
# DIALECT AWARENESS (PostgreSQL/PostGIS in prod, SQLite in dev)
# =============================================================================
# The production schema targets PostgreSQL + PostGIS, but dev/MVP must run on
# SQLite with no external services. We keep PostgreSQL-native behaviour on
# postgres via `.with_variant()` while degrading gracefully on SQLite.
_DATABASE_URL = os.getenv("DATABASE_URL", "")
IS_SQLITE = _DATABASE_URL.startswith("sqlite")
# JSON: JSONB on postgres, generic JSON (stored as TEXT) on sqlite
JSON_TYPE = JSON().with_variant(JSONB(), "postgresql")
# Text arrays: native ARRAY on postgres, JSON list on sqlite
STR_ARRAY_TYPE = JSON().with_variant(ARRAY(Text), "postgresql")
# Full-text search vector: TSVECTOR on postgres (trigger-populated), Text on sqlite
SEARCH_VECTOR_TYPE = Text().with_variant(TSVECTOR(), "postgresql")
if not IS_SQLITE:
# geoalchemy2 is only importable/usable against a spatial backend
from geoalchemy2 import Geometry
from geoalchemy2.functions import ST_SetSRID, ST_MakePoint
# ============================================================================= # =============================================================================
@@ -154,7 +175,7 @@ class Device(Base):
typical_frequency = Column(Integer, index=True) typical_frequency = Column(Integer, index=True)
frequency_range_low = Column(Integer) frequency_range_low = Column(Integer)
frequency_range_high = Column(Integer) frequency_range_high = Column(Integer)
modulation_types = Column(ARRAY(Text)) modulation_types = Column(STR_ARRAY_TYPE)
# Protocol Information # Protocol Information
protocol = Column(String(100), index=True) protocol = Column(String(100), index=True)
@@ -174,8 +195,8 @@ class Device(Base):
source = Column(String(50), index=True) # 'flipper', 'rtl433', 'urh', 'community' source = Column(String(50), index=True) # 'flipper', 'rtl433', 'urh', 'community'
source_url = Column(Text) source_url = Column(Text)
# Full-text search (auto-updated by trigger) # Full-text search (auto-updated by trigger on postgres)
search_vector = Column('search_vector', nullable=True) search_vector = Column('search_vector', SEARCH_VECTOR_TYPE, nullable=True)
# Relationships # Relationships
signatures = relationship("Signature", back_populates="device") signatures = relationship("Signature", back_populates="device")
@@ -218,6 +239,8 @@ class Capture(Base):
longitude = Column(DECIMAL(11, 8), nullable=False) longitude = Column(DECIMAL(11, 8), nullable=False)
altitude = Column(DECIMAL(8, 2)) altitude = Column(DECIMAL(8, 2))
gps_accuracy = Column(DECIMAL(6, 2)) gps_accuracy = Column(DECIMAL(6, 2))
# Spatial geometry only exists on PostGIS; SQLite uses lat/lon + haversine
if not IS_SQLITE:
geom = Column(Geometry('POINT', srid=4326)) # Auto-populated by trigger geom = Column(Geometry('POINT', srid=4326)) # Auto-populated by trigger
# Timestamps # Timestamps
@@ -254,7 +277,10 @@ class Capture(Base):
CheckConstraint('longitude >= -180 AND longitude <= 180', name='valid_longitude'), CheckConstraint('longitude >= -180 AND longitude <= 180', name='valid_longitude'),
CheckConstraint('match_confidence >= 0 AND match_confidence <= 1', name='valid_confidence'), CheckConstraint('match_confidence >= 0 AND match_confidence <= 1', name='valid_confidence'),
CheckConstraint('frequency >= 300000000 AND frequency <= 928000000', name='valid_frequency'), CheckConstraint('frequency >= 300000000 AND frequency <= 928000000', name='valid_frequency'),
Index('idx_captures_geom', 'geom', postgresql_using='gist'), ) + (
# GiST spatial index only applies to the PostGIS geometry column
(Index('idx_captures_geom', 'geom', postgresql_using='gist'),)
if not IS_SQLITE else ()
) )
# Relationships # Relationships
@@ -350,7 +376,7 @@ class CaptureMatch(Base):
# Match Details # Match Details
confidence = Column(DECIMAL(5, 4), nullable=False, index=True) confidence = Column(DECIMAL(5, 4), nullable=False, index=True)
match_method = Column(String(50), nullable=False) match_method = Column(String(50), nullable=False)
match_details = Column(JSONB) match_details = Column(JSON_TYPE)
# Timestamp # Timestamp
matched_at = Column(DateTime, default=datetime.utcnow) matched_at = Column(DateTime, default=datetime.utcnow)
@@ -386,7 +412,7 @@ class Identification(Base):
notes = Column(Text) notes = Column(Text)
# Visual Evidence # Visual Evidence
photo_urls = Column(ARRAY(Text)) photo_urls = Column(STR_ARRAY_TYPE)
# Community Validation # Community Validation
upvotes = Column(Integer, default=0) upvotes = Column(Integer, default=0)
@@ -540,7 +566,7 @@ class RTL433Protocol(Base):
bit_count = Column(Integer) bit_count = Column(Integer)
# JSON Fields Mapping # JSON Fields Mapping
json_fields = Column(JSONB) json_fields = Column(JSON_TYPE)
# Source # Source
source_file = Column(String(500)) source_file = Column(String(500))