Skip to main content
Glama
README.md
# relay

An agent-operable data pipeline for US power-grid data. `relay` ingests raw
public sources — CAISO OASIS, ERCOT MIS, EIA-930 — into a revision-aware
warehouse (ClickHouse in production; DuckDB as the hermetic dev/test tier)
and builds analytics marts (day-ahead/real-time price
spreads, daily peak demand, fuel-mix shares) with dbt. Every surface returns
typed, machine-readable results, so it can be driven equally by a human at a
terminal, a scheduler, or an AI agent over MCP.

Sibling project: [wattson](https://github.com/stxkxs/wattson) — grid data an
agent can *read*; relay is grid data infrastructure an agent can *operate*.

## The operating loop

```sh
relay describe      # what exists: datasets, buckets, lags, flows (cold-start discovery)
relay plan hourly   # exactly what `run` would do, computed from watermarks — no mutation
relay run hourly    # do it; exit 0 clean, 1 if any task failed (branchable)
relay status        # freshness: watermarks, row counts, staleness per dataset
relay backfill caiso --start 2026-06-01 --end 2026-06-08   # deliberate history
relay reprocess caiso   # re-parse landed bronze with current code — no network
```

From a fresh clone these run as `uv run relay …` (the Quickstart uses that
form); `uv tool install .` puts `relay` on your PATH directly. Every command
takes `--json` and prints the underlying contract
(`PlanResult`, `RunResult`, `StatusResult`, …) — the same Pydantic models
that validate at runtime and define the MCP tool schemas. Failures carry
stable codes (`fetch_failed`, `parse_failed`, `quality_failed`) an agent can
branch on. `plan` and `run` share one pending-window computation, so the plan
is a contract, not an estimate.

## Quickstart

**Prerequisites:** [uv](https://docs.astral.sh/uv/) only —
`curl -LsSf https://astral.sh/uv/install.sh | sh`. It provisions the pinned
Python 3.13 itself, so there is nothing else to install. Reviewing needs
neither network nor credentials; a live ingest needs network access (and, for
EIA-930 only, a free key).

### Review it — no keys, no network

```sh
uv sync
uv run pytest        # 156 hermetic tests from recorded fixtures — green in seconds
```

That exercises the whole pipeline offline — connectors, the revision merge,
DST conversion, the dbt gold build, CLI and MCP — with zero network and zero
credentials, so it is the fastest way to verify the work end to end. The full
CI gate is `uv run ruff check && uv run pyright && uv run pytest`.

### Run it live

```sh
cp .env.template .env       # add EIA_API_KEY — free, ~1 min: https://www.eia.gov/opendata/
uv run relay plan hourly    # what a run would fetch, from watermarks — no network, no mutation
uv run relay run hourly     # ingest CAISO + ERCOT + EIA-930; gold marts rebuild when silver changes
uv run relay status         # freshness per dataset
duckdb data/relay.duckdb "select * from mart_dart_spread limit 5"   # brew install duckdb, or any SQL client
```

CAISO and ERCOT need no key. **EIA-930 does** — without it, its two tasks
report `fetch_failed` and `run` exits `1` (by design: any failed task is a
non-zero, agent-branchable exit — not a crash). Set the free key above for a
fully green run, or stay keyless by ingesting only the public sources, e.g.
`uv run relay backfill caiso --start 2026-07-01 --end 2026-07-02` (pick a
recent window — sources retain only so much history).

Or the production topology on your laptop — a served ClickHouse warehouse
plus the relay image (`docker compose` reads `.env`, so the same EIA-930 note
applies):

```sh
docker compose up -d clickhouse
docker compose run --rm relay run hourly
docker compose run --rm relay status
```

As MCP tools (Claude Code): `claude mcp add relay -- uv run relay-mcp` — the
tools mirror the CLI 1:1.

## Architecture

```
CAISO OASIS (zip/CSV API)  ─┐   bronze: as-received payloads,        dbt
ERCOT MIS  (zip/CSV files) ─┼─▶ immutable, content-addressed  ─▶  silver: observations ─▶ gold marts
EIA-930    (JSON API, key) ─┘   parse -> validate -> merge          (revision-aware)       + tests
```

- **One silver schema.** Everything normalizes to long/tidy `observations`
  rows: `series_id` (hierarchical: `source.dataset.entity[.measure]`), `ts`
  (UTC interval start, half-open), `value`, `unit`, plus revision metadata.
  Nothing about a source's shape leaks downstream.
- **Revisions, not overwrites.** Grid data gets restated — real-time prices
  are corrected, EIA republishes past hours. The merge classifies each key:
  new → revision 0, identical → no-op, changed → a new revision with the old
  row's `is_current` flipped. History survives; replays are idempotent; and
  each dataset declares a `revision_lookback` — the runner re-fetches that
  trailing overlap on every run so restatements (EIA republishing 24h, RTM
  price corrections) become revisions instead of silent drift.
- **Watermarks own incrementality.** Per-dataset high-water marks, bucket-
  aligned windows, per-dataset publication lag. Failed tasks don't advance
  their watermark — the gap stays owed and appears in the next `plan`.
  Watermarks are monotonic, so backfills never regress freshness.
- **Time is handled once.** All local-time conventions (hour-ending labels,
  ERCOT's DST flag) convert through `timeconv`, with tests proving the
  25-hour fall-back day maps to 25 contiguous UTC hours and the
  spring-forward gap raises instead of silently shifting. EIA's UTC periods
  are hour-ending; interval start = period − 1h (convention documented and
  cited in the connector).
- **Bronze is replayable, not just evidence.** Every payload lands
  content-addressed and indexed with its original fetch time; `reprocess`
  re-parses bronze with today's code — corrected parses land as revisions,
  unchanged ones merge to no-ops, watermarks never move. A parser bug is a
  local replay, not a re-fetch against sources that only retain a month.
- **One warehouse contract, two engines.** The pipeline's semantics
  (revision merge, monotonic watermarks, artifact index) live in a `Warehouse`
  protocol with engine-native implementations: DuckDB flips an `is_current`
  flag inside a transaction; ClickHouse is insert-only and resolves currency
  at read time (`argMax` by revision). Both expose one `observations_current`
  relation — the only thing dbt reads — and a shared contract test suite
  asserts identical semantics of both. `RELAY_WAREHOUSE=clickhouse` selects
  production; the default keeps dev hermetic.
- **Connectors are two functions.** `fetch(dataset, window, http)` and
  `parse(payload) -> rows`. Landing, validation, merging, watermarks,
  politeness spacing, and 429 retry are the runner's, identically per source.
  Adding a source = one module + one registry entry.

## Data sources

| source | datasets | format | auth |
| --- | --- | --- | --- |
| CAISO OASIS | `lmp_dam` (hourly), `lmp_rtm` (5-min) — NP15/SP15/ZP26 hubs | zip of CSV over query API | none |
| ERCOT MIS | `spp_dam` (hourly), `spp_rtm` (15-min) — HB\_\* hubs | JSON doc listing + zip of CSV | none |
| EIA-930 | `demand`, `fuel_mix` (hourly) — 7 BAs | JSON API v2, paginated | free key |

Marts: `mart_dart_spread` (day-ahead vs realized real-time price per hub per
hour), `mart_daily_peak_demand`, `mart_fuel_mix_share`, `mart_freshness`.
The marts carry dbt schema tests plus singular tests (spread arithmetic,
shares sum to 1, composite uniqueness) — 27 dbt tests in every gold build.

## Testing

156 hermetic pytest tests (coverage-gated ≥85% in CI) + a 21-test
cross-engine warehouse-contract suite (DuckDB always; ClickHouse against a
real container under the opt-in `clickhouse` marker) + 27 dbt tests — the
hermetic default has zero network, zero real sleeps: recorded
fixtures for connectors (provenance documented per fixture directory —
recorded-live vs synthetic-to-documented-format is always disclosed),
injected clocks for the scheduler/retry paths, `httpx.MockTransport` at the
fetch boundary, a seeded-warehouse dbt build, and CLI/MCP tests against a
stubbed runner. The DST fall-back/spring-forward days and the revision
lifecycle each have dedicated tests — those are the bugs this domain
actually produces.

Gates (all enforced in CI): `ruff format --check` · `ruff check` · `pyright`
(strict) · `pytest`.

## Scheduling

GitHub Actions runs the pipeline (`.github/workflows/pipeline-hourly.yml`)
once the `PIPELINE_ENABLED` repo variable is `true`: hourly incremental
ingestion, with the gold marts (and their 27 dbt tests) rebuilt in the same
run whenever silver changed. The
`RELAY_WAREHOUSE` repo variable selects the tier: against ClickHouse,
silver/gold persist in the served warehouse and only bronze rides the
object-store sync; on the DuckDB tier the whole data dir syncs to any
S3-compatible bucket (R2) when `STATE_BUCKET` / `STATE_ENDPOINT_URL` vars +
access-key secrets exist. Without a bucket each run is stateless but still
correct. Every run uploads its `RunResult` JSON as an
artifact — the audit trail is the contract, not log grep.

Python 3.13 (pinned — dbt-core does not yet support 3.14) · uv · polars ·
ClickHouse + DuckDB · dbt · Typer · MCP.

Operational details — failure modes by error code, source quirks, backfill
etiquette — live in [`docs/runbook.md`](docs/runbook.md). The trade-offs
behind the architecture (and what would trigger revisiting each) are recorded
in [`docs/decisions.md`](docs/decisions.md).

## License

[Apache 2.0](LICENSE).