Skip to content

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

db/engine.py
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 use
  • pool_recycle=3600 -- refreshes connections every hour
  • asyncpg driver 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:

db/models/__init__.py
# 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:

db/models/charging_session.py
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

uv run alembic revision --autogenerate -m "add new column"
uv run alembic upgrade head
uv run alembic revision -m "add new column"
# Edit the generated file in db/migrations/versions/
uv run alembic upgrade head

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:

web/dependencies.py
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.