New website under development. In the meantime, follow EV Fleet (Pty) Ltd on LinkedIn for updates.
Status: implemented, Phases 0-7 complete (see postgres-implementation-plan.md).
Postgres is a required dependency: db_config.ini must exist and be reachable, or
every write/read in src/data_processing_ev/db/ raises rather than silently skipping
(reversing this doc’s original “no-op if unconfigured” design — see §1 and §5 below).
Scope: this covers Step 2 (save fleet + per-vehicle summary stats to Postgres) and the
Postgres-read half of Step 4 from Chris’s board (see
../brain/skills/SIM_DATABASE_IMPLEMENTATION.md → “Current Direction”). The Parquet/DuckDB
time-series side (Steps 1’s Parquet half, Step 3, Step 4’s DuckDB half) is Chris’s slice, not
covered here.
The checkout was found on branch brain-phase1-skip-plots-flags, 363 commits behind
origin/brain-phase1-skip-plots-flags, with 3 files mid-revert (sim_worker/engine.py,
ev_box_plots.py, ev_results_analysis.py — rolling back the rejected BRAIN_SIM_SKIP_*
env-var MR). Sync with origin and decide what branch to actually build this on before
creating any of the files below — don’t layer new commits on a stale/mid-revert tree.
git fetch origin
git log --oneline HEAD..origin/brain-phase1-skip-plots-flags | wc -l # confirm the gap
git status --short # confirm the 3 mid-revert files
Chris’s explicit direction (MR !5 rejection + follow-up call): options via a config file, not
env vars, and keep the config surface minimal. No app-config mechanism exists anywhere in
data_processing_ev today (confirmed by grep — only CLI flags and, in the separate
sim_worker/ component, its own unrelated .env).
File: src/data_processing_ev/db/config.py
configparser INI — stdlib, no new dependency.--config flag:
Path.home() / ".config" / "ev-fleet-sim" / "db_config.ini"
exposed as DEFAULT_CONFIG_PATH (overridable by tests via the function arg, not an env var).load_db_config() returns None, and db/connection.py’s get_connection() turns
that into a raised RuntimeError — Postgres is a required dependency, not an optional one.
This keeps the config surface to exactly one thing — matches Chris’s minimize-config-surface
note — while still failing loudly rather than silently.DEFAULT_CONFIG_PATH is currently
~/.config/ev-fleet-sim/db_config.ini (user-level). For a system-level Sim installation,
/etc/ may be the more correct location — raised in MR !6 review, not decided yet.db_config.ini.example (commit this at repo root; add bare db_config.ini to .gitignore):
[postgres]
host = localhost
port = 5442
dbname = ev_fleet_sim
user = ev_fleet_sim
password = dev-only-change-me
sslmode = prefer
No docker-compose.yml exists in this repo yet. Add one at repo root:
# docker-compose.yml — local dev Postgres for ev-fleet-sim summary storage.
# Throwaway dev credentials only — not used for Sim's own app config (that's
# db_config.ini, read by db/config.py), just to seed this container.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ev_fleet_sim
POSTGRES_PASSWORD: dev-only-change-me
POSTGRES_DB: ev_fleet_sim
ports:
# Host port 5442, not 5432 — this dev machine already runs a native
# Postgres on 5432 (found via Phase 1's Agent Self-Test, `lsof -i :5432`).
- "5442:5432"
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ev_fleet_sim -d ev_fleet_sim"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pg_data:
Bring it up, then copy the example config so Sim can find it:
docker compose up -d postgres
mkdir -p ~/.config/ev-fleet-sim
cp db_config.ini.example ~/.config/ev-fleet-sim/db_config.ini
No manual migration step needed — db/connection.py’s ensure_schema() runs
CREATE TABLE IF NOT EXISTS on first write (see §4).
Declared in setup.cfg (this repo’s single source of dependency truth — pyproject.toml is
4 lines, build-backend only). Add to [options] install_requires:
psycopg[binary] >= 3.1
Why psycopg3, raw SQL, no ORM/no sqlalchemy, no asyncpg:
main() runs numbered steps sequentially,
no async def anywhere in the codebase) — asyncpg would introduce a concurrency model
the rest of the package doesn’t have, just to write two summary rows.sqlalchemy brings an ORM + its own migration story for what is, in this slice, two tables
and a handful of SQL statements — more machinery than the problem needs right now.psycopg3’s built-in jsonb adaptation (dict ↔ Jsonb on write, automatic decode to
dict on read) fits the JSONB-heavy schema below with no extra registration code.Also add (doesn’t exist yet in setup.cfg):
[options.extras_require]
test =
pytest >= 7.0
src/data_processing_ev/db/schema.sql)Verified against the actual stats_tree shapes in ev_results_analysis.py:
save_ev_stats(), ~line 694, JSON write at 946-952):
{"stats": {"averages": {"energy diffs": {period: {mean, stdev}, ...}}, "dates": [...]}}save_fleet_stats(), ~line 1045, JSON write at 1197-1200):
{"stats": {"averages": {"energy diffs": {period: {mean, stdev}, ...}}, "EVs": [...]}}
(no dist diffs, uses EVs not dates — genuinely different shape from the per-EV tree)period keys are dynamically generated ("00:00:00 -> 06:00:00", etc.) and contain spaces/
arrows — not valid SQL identifiers, and fragile to flatten into typed columns. Hybrid design:
a few typed identity columns + the irregular parts as JSONB, raw_stats kept verbatim as a
round-trip safety net.
CREATE TABLE IF NOT EXISTS fleet_stats (
id bigserial PRIMARY KEY,
run_id uuid NOT NULL UNIQUE,
scenario_name text NOT NULL,
scenario_path text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
averages_energy_diffs jsonb NOT NULL,
evs jsonb NOT NULL,
raw_stats jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS fleet_stats_raw_stats_gin ON fleet_stats USING gin (raw_stats);
CREATE TABLE IF NOT EXISTS vehicle_stats (
id bigserial PRIMARY KEY,
run_id uuid NOT NULL,
scenario_name text NOT NULL,
scenario_path text NOT NULL,
ev_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
averages_energy_diffs jsonb NOT NULL,
dates jsonb NOT NULL,
raw_stats jsonb NOT NULL,
UNIQUE (run_id, ev_name)
);
CREATE INDEX IF NOT EXISTS vehicle_stats_raw_stats_gin ON vehicle_stats USING gin (raw_stats);
Sim has no existing “run id” concept today — one scenario_dir = one invocation, reruns
overwrite the same JSON files in place. This design mints a run_id (uuid.uuid4()) once
per run_ev_results_analysis() call, shared by the fleet row and all vehicle rows from that
run, and treats Postgres as append-only across reruns — unlike the JSON files, Postgres
keeps every run’s history rather than clobbering it. This is the reason to use a database
instead of flat files at all, and it’s what Step 5’s REST API will want (list runs for a
scenario, not just “the latest”). Confirmed 2026-07-23: append-only, run_id UNIQUE
schema — not upsert-on-scenario. PG-T0.2 closed; Phase 1’s schema task (PG-T1.4) is unblocked.
src/data_processing_ev/db/summary_writer.py — two functions:
write_ev_stats(scenario_dir, run_id, ev_name, stats_tree) and
write_fleet_stats(scenario_dir, run_id, stats_tree). Each:
db.connection.get_connection(). No config file, or Postgres unreachable → raises
(RuntimeError for unconfigured, psycopg’s own error for unreachable/misconfigured) —
Postgres is required, so a broken Postgres now surfaces as a failed run rather than a
silently-missing row.ensure_schema(), INSERT ... ON CONFLICT (run_id[, ev_name]) DO NOTHING.Exact edit sites in ev_results_analysis.py:
run_ev_results_analysis() (line 1404): kwargs.setdefault('run_id', uuid.uuid4()) before
constructing Data_Analysis — kwargs already flows unmodified into both
save_ev_stats(**kwargs) / save_fleet_stats(**kwargs) (lines 1408-1409), same pattern
already used for auto_run.json.dump(...) block at lines 946-952 (save_ev_stats): call
db.summary_writer.write_ev_stats(...).json.dump(...) block at lines 1197-1200 (save_fleet_stats): call
db.summary_writer.write_fleet_stats(...).Runs alongside the JSON write, never replaces it, no gating flag. Other consumers (Brain’s
interim sim_export_parser.py bridge) still read the JSON files until Brain’s own Step 6
ships — pulling them now breaks a live consumer. The only switch this introduces is
“does db_config.ini exist” — matches the minimal-surface direction from MR !5’s rejection.
auto_run (API vs. CLI) is not a gate for this write — Postgres writing happens the same
way regardless of invocation mode; auto_run governs prompt/plot suppression, not persistence.
src/data_processing_ev/db/reader.py)For Step 4’s Postgres half — plain functions, plain dicts, no framework, so Step 5’s REST
layer (Chris, later) can call them directly and json-serialize the result:
get_fleet_stats(conn, run_id) -> Optional[dict]get_vehicle_stats(conn, run_id, ev_name) -> Optional[dict]list_vehicle_stats(conn, run_id) -> list[dict]get_latest_run_id(conn, scenario_name) -> Optional[uuid.UUID] (small addition beyond the
literal ask — near-certain need for Step 5 to resolve “latest run for scenario X”)Each returns raw_stats verbatim — guarantees the round-trip property Step 4’s own test
demands by construction (it’s the exact dict serialized on write).
No test infra exists in this package today (only sim_worker/tests/, which is Brain-owned;
.gitlab-ci.yml only runs a Jekyll docs build). New tests/ at repo root:
tests/test_db_config.py — pure unit test of load_db_config() against a fixture INI, no
live DB.tests/test_db_summary_writer.py, tests/test_db_reader.py — integration tests against the
docker-compose Postgres from §2. Round-trip check: hand-built stats_tree fixture → write →
read → assert raw_stats deep-equals the input exactly.ev_results_analysis.py, independent of whether the Postgres write
succeeds.ci-proposal-postgres-tests.md, confirmed with
Chris (option 1), and is now wired into .gitlab-ci.yml.docker compose up -d postgres
pytest tests/ -q
db/config.py, db/connection.py (§1, §3, §4 schema.sql)db/summary_writer.py + setup.cfg dep (§3, §5)docker-compose.yml + db_config.ini.example (§2) — needed to exercise the above locallyev_results_analysis.py (§5) — smallest, most reviewable diff once the above is soliddb/reader.py (§6) — can build in parallel with step 6 once schema is fixed