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:
+30
-5
@@ -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
@@ -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
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user