CountryAtlas data pipeline (ca)
The pipeline turns external statistical series into one read-only DuckDB snapshot (atlas.duckdb) that the API
serves. It is deterministic, idempotent and never edits the live database in place (ARCHITECTURE §2 snapshot swap).
registry/*.yaml ──┐
▼
connectors ──► raw/ (gzip, forever) ──► normalize ──► validate ──► staging/<connector>/<spec>.parquet
│
build ◄────────────────────────────────────────────┘
│ registry tables → merge by priority → revisions → latest/rankings/coverage
│ → changes/events → similarity/DNA → insights → search_index → meta → integrity
▼
build/atlas-<run_id>.duckdb ──os.replace──► atlas.duckdb (+ snapshots/atlas-<run_id>.duckdb, keep 7)Commands
| Command | What it does |
|---|---|
ca registry validate |
YAML sanity (unique slugs, topics, connectors, similarity.yaml / insights.yaml references). Exit 1 on hard errors. |
ca fetch [-c worldbank] [-i gdp] |
Download → store raw → normalize → validate → staging, one unit of work per source spec, concurrently (CA_HTTP_CONCURRENCY, default 6). |
ca normalize [-c X] |
Same, but re-reads the newest raw files instead of downloading (re-run after fixing a connector). |
ca validate [-c X] |
Re-applies the generic rules to every staging file and rewrites statuses + .issues.json. |
ca build [--no-strict] [--no-swap] |
New snapshot from staging; derived tables; integrity checks; atomic swap + snapshot copy. |
ca refresh [-c X] |
fetch + build under one run id (what the scheduler runs). |
ca schedule [--now] |
Loop: refresh daily at CA_REFRESH_HOUR:CA_REFRESH_MINUTE (03:15 America/Toronto); kill -USR1 <pid> refreshes now; heartbeat in data_dir/scheduler.json; logs in logs/refresh-<date>.log. |
ca status |
Connectors (implemented, specs, staging files, last run, ok/failed), failed specs, snapshot meta, scheduler heartbeat. |
ca export indicator <slug> -f csv|json|parquet / ca export country <ISO3> |
Files under exports/; the API reuses countryatlas.pipeline.export. |
ca sql "select …" / ca sql |
Read-only DuckDB query / mini REPL on the live snapshot. |
Everything runs with .venv/bin/ca …. Data lives in CA_DATA_DIR (default ~/countryatlas-data).
Where files live (CA_DATA_DIR)
raw/<connector>/<dataset>/<YYYY-MM-DD>/<code>-<sha1[:10]>.{json|csv}.gz payload exactly as served (kept forever)
<code>-<sha1[:10]>.{json|csv}.meta.json url, retrieved_at, source_updated_at, pages, meta
staging/<connector>/<dataset>__<code>__<indicator>.parquet NormalizedObservation columns (+ status), one file per spec
staging/<connector>/<…>.run.json ImportRun of the LAST attempt (ok|partial|failed|quarantined)
staging/<connector>/<…>.issues.json validation issues of the last successful write (capped)
staging/<connector>/<…>.meta.json source_url, notes (sourceNote…), source_updated_at, licence
build/atlas-<run_id>.duckdb work in progress (deleted on failure)
atlas.duckdb live snapshot (API opens read-only)
snapshots/atlas-<run_id>.duckdb last CA_KEEP_SNAPSHOTS (7) successful builds
exports/indicators/<slug>.<fmt>, exports/countries/<ISO3>.<fmt>
logs/refresh-<date>.log, scheduler.jsonShared OWID CSVs (co2, energy) are downloaded once per run and stored once (raw code owid-co2-data /
owid-energy-data); store_raw de-duplicates by content hash, so re-running on the same day is free.
The unit of work: one IndicatorSourceSpec
Each entry of indicators[].sources (plus registry/sources/<connector>.yaml) is processed in isolation
(pipeline/fetch.py::process_spec):
connector.fetch(spec)→ one or moreRawPayload(pagination inside the connector) →store_raw.connector.normalize(raw, spec)→list[NormalizedObservation](ISO3 viaregistry.lookup(), aggregates dropped,spec.transformapplied,is_forecast/is_estimateset).connector.validate(rows)— duplicates → dataset quarantined.- Generic rules (
pipeline/validate.py, below) →statusper row, issues, possibly dataset quarantine. - Atomic write of the parquet + sidecars.
Any exception → run.json with status: failed and the message; the previous parquet stays. A connector whose module
is not implemented yet is skipped with a warning (its specs are simply not staged). Retired World Bank codes are reported
as failed with the API message and a "RETIRED … fix the registry mapping" hint.
Validation & quarantine
Row statuses (observations.status): imported (default) · warning (extreme jump, kept) · stale (the country's
latest period ended more than stale_after_days ago; only that latest row) · quarantined (outside registry bounds
or non-finite). Nothing is deleted. Quarantined rows stay in observations for inspection but are excluded from every
derived table (latest, rankings, changes, events, similarity, insights, country_dna).
Row statuses also cover impossible_year (year < 1750 or > current year + 10 → quarantined).
Dataset-level quarantine (quarantine_dataset=True): schema_change (a required staging column is missing or has an
incompatible dtype), duplicates, unit ≠ registry unit, or fewer than 30 % of the rows of the previous staging file for the
same spec (partial_download). The new file is not written, the previous one is kept, and the run.json says
quarantined with the reason. ca status lists these.
Dataset-level warnings compared with the previous staging file of the same spec (PreviousStats, read by
fetch.py before validation): null_spike (share of null values > 3 × the previous share and > 5 %) and vintage_shift
(median |relative change| over ≥ 20 overlapping keys > 25 % — a source revision or a changed definition). Both are logged,
stored in validation_issues, and the file is still written: suspicious values are flagged, never deleted.
HTTP reliability (connectors/base.py::Connector.get): every request goes through one method with 5 attempts and a
deterministic exponential backoff (2, 4, 8, 16 s, capped at 60 s, no jitter) on 408/425/429/5xx and transport errors
(timeouts, resets); a Retry-After header (seconds or HTTP-date) is honoured before the backoff, capped at 120 s.
Extreme jumps: positive level series (format in currency/number/tonnes/kwh/per_*/km/ha with a lower bound ≥ 0) are
compared on log-differences, others on absolute differences; threshold = jump_threshold × 1.4826 × MAD of the
country's differences, with a floor of 10 % (relative) or 2 % of the series range (absolute); at least 5 points.
Build (pipeline/build.py, ≈ 10 s for 2.3 M staging rows)
schema.sql→ registry tables (countries,groups,group_members,sources,indicators,indicator_sourcesincl.source_url/notesfrom the staging sidecars).- All staging parquet files whose spec still exists in the registry →
staging_all(joined withindicator_sources.priority); files of removed/renamed specs are orphans, ignored with a warning (delete them or re-map the spec;ca validatelists them too). Source selection is per series(country, indicator, frequency): the highest-priority source with non-forecast data for that country wins the whole series, unless a lower-priority source is > 3 years fresher (metadata.merge_reason=priority|fresher). Sources are never spliced inside a series; every other source's complete series goes toobservations_alt. Forecast rows of the chosen source are kept (dashed on charts) but never enter derived tables. observation_revisions: the previousatlas.duckdbis attached read-only; every key whose value or source changed is recorded with the newrun_id; the previous revisions table is copied over.import_runs(latestrun.jsonper spec) andvalidation_issues(issues sidecars).- Derived tables (
pipeline/derived.py, SQL) are computed fromobs_ok= non-forecast, non-quarantined observations at the indicator's canonical frequency only (a monthly FRED series never becomes a country's "latest" for an annual indicator):latest(prev period, 10-year change, ranks within the same year amongkind='country'— world / WB region / income group),rankings(ranking-eligible indicators, years with ≥ 20 countries),coverage, indicator/source coverage columns,search_index. Rank direction: rank 1 = lowest value whenhigher_is_better = false, otherwise highest value ("best" whenhigher_is_betteris set, "highest" when null).pct_rank = 1 − (rank−1)/(n−1). changes/events(pipeline/changes.py, numpy per series, ≈ 45 k series in ~3 s) — see ARCHITECTURE §7/§7.1 and the module docstring; headlines are English templates such as "Inflation fell 3.4 points to 3.4 % in 2025 (largest drop since 2009)."changesonly keep detections at a series' latest period when that period is recent (≤ 2 years behind the indicator's max year and ≤ 3 years behind today); monotone series andcumulative-tagged indicators are silent; severity = detector score × importance weight (headline 1.0 / featured 0.9 / other 0.7). Q/M series are annualised first (one detection per year at most); index / non-rankable series need a ≥ 10 % move;eventskeep severity ≥ 0.35 and ≤ 3 rows per (country, indicator, year). Change detection 2.0 (countryatlas.stats):structural_break— single mean shift (binary segmentation) with gain ≥ 0.5 of the total sum of squares, |shift| ≥ 1.5 × sd, ≥ 5 years on each side, non-monotone series; achangewhen the break is within the last 10 years, always anevent(one per series);trend_reversal— three yearly moves of one sign after three of the opposite sign, cumulative move ≥ floor (severity 0.55);volatility_spike— sd of the last 5 yearly differences ≥ 3 × sd of the previous 15 and ≥ floor (severity 0.45–0.7). Real snapshot (2026-09-12): 3 355 breaks, 1 799 volatility spikes (mostly life expectancy 2019–2024), 546 reversals inchanges; 19 593 break events.similarity(5 modes,registry/similarity.yaml) andcountry_dna(9 percentile dimensions,dna:section of the same file). Each contribution carriesz_a,z_b,weight,contributionand the raw latest valuesvalue_a/value_b(after an optionalperratio, before the log transform) so the UI can quote real numbers. 8.insights(registry/insights.yaml, 16 templates, all numbers computed).meta(schema_version, build_run_id, built_at, counts,previous_observation_count,source_healthJSON = per connector ok / partial / failed / quarantined spec counts fromimport_runs, connectors, duration) →CHECKPOINT.- Integrity: ≥ 100 000 observations when World Bank staging exists; every headline indicator that has staging data
has ≥ 100 countries in
latest; the new snapshot has ≥ 90 % of the previous snapshot's observations (a shrunken build is never published). Failure (or any exception) deletes the build file and leaves the live DB untouched. os.replace→atlas.duckdb, copy tosnapshots/, prune toCA_KEEP_SNAPSHOTS. Every build (published or failed) writeslogs/build-<run_id>.json(counts, warnings, error, staging files used per connector).
Adding a connector
-
Create
src/countryatlas/connectors/<id>.pywith a subclass ofcountryatlas.connectors.base.Connector:pythonclass FooConnector(Connector): id = "foo"; name = "Foo Stats"; organization = "…"; url = "…"; licence = "…"; attribution = "…" api_base = "https://…"; rate_per_minute = 60; country_codes = "iso3" def fetch(self, spec: IndicatorSourceSpec) -> RawPayload | list[RawPayload]: r = self.get(f"{self.api_base}/{spec.code}", params=spec.params) # retries/backoff/rate limit built in return self.payload(r, dataset=spec.dataset, code=spec.code, source_url=…, notes=…) def normalize(self, raw, spec) -> list[NormalizedObservation]: lk = lookup(); unit = indicators_by_id()[spec.indicator_id].unit … iso3 = lk.from_iso3(code) / from_iso2 / from_name … (None → drop) … value = self.apply_transform(float(v), spec.transform) …Put
source_url/notesinRawPayload.meta— the pipeline copies them intoindicator_sources. Raisecountryatlas.connectors._util.ConnectorErrorfor non-transient problems (unknown code, empty dataset). If one raw file serves many specs, cache it in the instance and implementraw_code_for(spec)soca normalizecan find it. -
Add one line to
CONNECTORSinsrc/countryatlas/connectors/__init__.py("foo": "countryatlas.connectors.foo:FooConnector"). The package is also scanned forConnectorsubclasses, so a missing entry is not fatal. -
Map indicators:
sources:entries inregistry/indicators.yamlor aregistry/sources/foo.yamlfile (sources: [{indicator, dataset, code, params, priority, transform, countries, frequency, notes}]). Addfootoregistry.CONNECTORSif it is a new id. -
ca fetch -c foo -i <one-indicator>→ check the staging parquet, thenca buildandca status.
Tests: .venv/bin/python -m pytest tests/ (fixtures in tests/fixtures/ are small recorded subsets of real payloads).