Database¶
LightningROD uses PostgreSQL 16 with SQLAlchemy 2.0 in async mode. The schema is designed around the ha-fordpass data model, with 8 tables covering vehicle telemetry even though v1 only populates charging data.
Schema Overview¶
Core Tables¶
| Table | Columns | Purpose |
|---|---|---|
ev_charging_session |
30 | Charging events: timing, energy, cost, location, SOC |
ev_battery_status |
21 | HV/12V battery snapshots: SOC, voltage, current, temperature |
ev_vehicle_status |
31 | Drivetrain, temperatures, tire pressure, door locks |
ev_trip_metrics |
26 | Per-trip energy, distance, efficiency, driving scores |
ev_location |
13 | GPS snapshots with optional reverse geocoding |
Reference Tables¶
| Table | Columns | Purpose |
|---|---|---|
ev_charging_networks |
5 | User-configured network costs per location |
ev_location_lookup |
6 | Known locations for geofence matching |
app_settings |
3 | Key-value store for user preferences and toggles |
Why 8 tables when v1 only uses charging data?
The schema is designed for the full ha-fordpass data model so it's ready for live ingestion. Adding the adapter later doesn't require schema changes -- only new data flowing into existing tables.
Connection Management¶
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from config import settings
engine = create_async_engine(
settings.database_url,
pool_pre_ping=True,
pool_recycle=3600,
echo=settings.debug,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
Key settings:
pool_pre_ping=True-- validates connections before usepool_recycle=3600-- refreshes connections every hourasyncpgdriver for native async PostgreSQL access
Models¶
Models live in db/models/ with one file per domain area. The __init__.py imports all model classes so Alembic's autogenerate can discover every table:
# Import Base and ALL model classes so Alembic's autogenerate sees all tables.
# CRITICAL: Every model module must be imported here. If a module is missing,
# alembic revision --autogenerate will produce an empty migration.
from db.models.base import Base
from db.models.battery_status import EVBatteryStatus
from db.models.charging_session import EVChargingSession
from db.models.location import EVLocation
from db.models.reference import EVChargingNetwork, EVLocationLookup, EVStatistics, AppSettings
from db.models.trip_metrics import EVTripMetrics
from db.models.vehicle_status import EVVehicleStatus
__all__ = [
"Base",
"EVChargingSession",
"EVBatteryStatus",
"EVTripMetrics",
"EVLocation",
"EVVehicleStatus",
"EVChargingNetwork",
"EVLocationLookup",
"EVStatistics",
"AppSettings",
]
Example: Charging Session¶
The largest model, with 30 columns covering the full lifecycle of a charging event:
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, Index, Integer, Numeric, String, UniqueConstraint, text
from sqlalchemy.dialects.postgresql import TIMESTAMP
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column
from db.models.base import Base
# PostgreSQL TIMESTAMPTZ — all timestamps must have timezone info
TIMESTAMPTZ = TIMESTAMP(timezone=True)
class EVChargingSession(Base):
"""EV charging session records (30 columns).
Source: 002_create_target_tables.sql, ev_charging_session table.
"""
__tablename__ = "ev_charging_session"
# Primary identifier columns
id: Mapped[int] = mapped_column(primary_key=True)
session_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), default=uuid.uuid4, nullable=False
)
device_id: Mapped[str] = mapped_column(String, nullable=False)
# Session type and location
charge_type: Mapped[Optional[str]] = mapped_column(String)
location_name: Mapped[Optional[str]] = mapped_column(String)
location_type: Mapped[Optional[str]] = mapped_column(String(20)) # 'home', 'work', 'public'
is_free: Mapped[Optional[bool]] = mapped_column(Boolean) # whether session was free charging
plug_status: Mapped[Optional[str]] = mapped_column(String)
charging_status: Mapped[Optional[str]] = mapped_column(String)
station_status: Mapped[Optional[str]] = mapped_column(String)
# Power metrics
charging_voltage: Mapped[Optional[float]] = mapped_column(Numeric)
charging_amperage: Mapped[Optional[float]] = mapped_column(Numeric)
charging_kw: Mapped[Optional[float]] = mapped_column(Numeric)
# Timestamps (all TIMESTAMPTZ)
session_start_utc: Mapped[Optional[datetime]] = mapped_column(TIMESTAMPTZ)
session_end_utc: Mapped[Optional[datetime]] = mapped_column(TIMESTAMPTZ)
estimated_end_utc: Mapped[Optional[datetime]] = mapped_column(TIMESTAMPTZ)
recorded_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMPTZ)
# Duration columns
charge_duration_seconds: Mapped[Optional[float]] = mapped_column(Numeric)
plugged_in_duration_seconds: Mapped[Optional[float]] = mapped_column(Numeric)
# SOC and energy
start_soc: Mapped[Optional[float]] = mapped_column(Numeric)
end_soc: Mapped[Optional[float]] = mapped_column(Numeric)
energy_kwh: Mapped[Optional[float]] = mapped_column(Numeric)
# Cost
cost: Mapped[Optional[float]] = mapped_column(Numeric)
cost_without_overrides: Mapped[Optional[float]] = mapped_column(Numeric)
cost_source: Mapped[Optional[str]] = mapped_column(String(20)) # 'imported', 'manual', 'calculated', None
# Session flags
is_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Location and power range
location_id: Mapped[Optional[int]] = mapped_column(Integer)
max_power: Mapped[Optional[float]] = mapped_column(Numeric)
min_power: Mapped[Optional[float]] = mapped_column(Numeric)
miles_added: Mapped[Optional[float]] = mapped_column(Numeric)
# Pipeline metadata
source_system: Mapped[Optional[str]] = mapped_column(String(100))
ingested_at: Mapped[datetime] = mapped_column(
TIMESTAMPTZ, nullable=False, server_default=text("NOW()")
)
original_timestamp: Mapped[Optional[datetime]] = mapped_column(TIMESTAMPTZ)
__table_args__ = (
UniqueConstraint("session_id", name="uq_ev_charging_session_session_id"),
Index("idx_ev_charging_session_session_start_utc", "session_start_utc"),
Index("idx_ev_charging_session_device_id", "device_id"),
Index("idx_ev_charging_session_source_system", "source_system"),
Index(
"idx_ev_charging_session_is_complete",
"is_complete",
postgresql_where=text("is_complete = true"),
),
)
Migrations¶
Alembic manages schema versioning with an async-compatible env.py.
Current Migrations¶
| Migration | Description |
|---|---|
2b6f55486b4d |
Initial schema -- all 8 tables |
7086caea2990 |
Add location_type, is_free, session_id unique constraint |
c9345e830aab |
Phase 4 cost schema -- is_free on networks, cost_source, app_settings table |
Creating a New Migration¶
Tip
When writing manual migrations, look at the existing files in db/migrations/versions/ for patterns. The Phase 4 migration (c9345e830aab) is a good example of a migration that adds columns, creates a new table, and seeds default data.
Dependency Injection¶
Each request gets its own database session via FastAPI's dependency system:
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
from db.engine import AsyncSessionLocal
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
Route handlers declare the dependency:
@router.get("/sessions")
async def sessions(request: Request, db: AsyncSession = Depends(get_db)):
...
The session is automatically committed on success and closed after the request.