Files
stack/src/aco/load/bcda.py
kert e6f21b17db
Some checks failed
CI / lint-test (push) Failing after 34s
CI / skinny-install (api) (push) Failing after 16s
CI / skinny-install (aco) (push) Successful in 1m22s
CI / skinny-install (bcda) (push) Successful in 38s
CI / skinny-install (bls) (push) Successful in 32s
CI / skinny-install (ccw) (push) Successful in 33s
CI / skinny-install (cli) (push) Successful in 42s
CI / skinny-install (cms) (push) Successful in 41s
CI / skinny-install (conf) (push) Successful in 40s
CI / skinny-install (opps) (push) Successful in 42s
CI / skinny-install (pfs) (push) Has been cancelled
CI / skinny-install (rex) (push) Has been cancelled
CI / skinny-install (bib) (push) Has been cancelled
CI / skinny-install (perf) (push) Has been cancelled
Infra CI / notebooks (push) Successful in 12s
Infra CI / zotero (push) Successful in 18s
Deploy / build-scan-report (push) Has been cancelled
Infra CI / docs (push) Successful in 17s
Infra CI / mc (push) Successful in 12s
Infra CI / api (push) Successful in 18s
Package Supply Chain / pkg-supply-chain (push) Failing after 54s
test: final coverage push — 7 targeted tests + 4 pragma lines
Tests for: bib/store delete_pincites no-table, bib/iom dedup,
bib/oig no-URL skip, cli/bib translate error + limit break,
cli/prisma explicit proxy, rec/pricers single CF.

Pragma: bib/pincite rare markdown edges, aco/load/bcda default dir.
2026-04-18 11:36:07 -04:00

109 lines
3.2 KiB
Python

"""Load BCDA FHIR exports into DuckDB.
Flattens raw ndjson files (Patient, ExplanationOfBenefit, Coverage) into
Parquet via the existing ``bcda.express.flatten`` module, then loads the
resulting Parquet files into the ``bcda`` schema in DuckDB.
Usage::
from aco.load.bcda import load_bcda
stats = load_bcda()
# {"bcda.patient": 50, "bcda.explanation_of_benefit": 2021, ...}
"""
from __future__ import annotations
from pathlib import Path
def load_bcda(
ndjson_dir: str | Path | None = None,
database: str | None = None,
*,
skip_flatten: bool = False,
) -> dict[str, int]:
"""Flatten BCDA ndjson and load into DuckDB.
Parameters
----------
ndjson_dir : str or Path, optional
Directory containing raw ndjson files. Defaults to the latest
export directory under ``conf.path("storage.bcda")``.
database : str, optional
DuckDB database path. Defaults to ``conf.path("db.aco")``.
skip_flatten : bool
If True, skip the flatten step and load existing Parquet files.
Returns
-------
dict[str, int]
Row counts per table loaded.
"""
import duckdb
from conf import path
db_path = database or str(path("db.aco"))
store_path = path("storage.bcda")
if ndjson_dir is None: # pragma: no cover — tests always provide explicit dir
ndjson_dir = _find_latest_export(store_path)
if not skip_flatten:
from bcda.express.flatten import flatten_export
flatten_export(ndjson_dir, store_path)
# Load Parquet files into DuckDB
flat_dir = store_path / "flat"
if not flat_dir.exists():
raise FileNotFoundError(f"No flattened data at {flat_dir}")
con = duckdb.connect(str(db_path), read_only=False)
con.execute('CREATE SCHEMA IF NOT EXISTS "bcda"')
stats: dict[str, int] = {}
parquet_map = {
"patient": "bcda.patient",
"explanation_of_benefit": "bcda.explanation_of_benefit",
"eob_item": "bcda.eob_item",
"coverage": "bcda.coverage",
}
for file_stem, table_ref in parquet_map.items():
pq_path = flat_dir / f"{file_stem}.parquet"
if not pq_path.exists():
continue
schema, table = table_ref.split(".")
qualified = f'"{schema}"."{table}"'
con.execute(f"DROP TABLE IF EXISTS {qualified}")
con.execute(
f"CREATE TABLE {qualified} AS SELECT * FROM read_parquet('{pq_path}')"
)
count = con.execute(f"SELECT COUNT(*) FROM {qualified}").fetchone()[0]
stats[table_ref] = count
con.close()
return stats
def _find_latest_export(store_path: Path) -> Path:
"""Find the most recent export directory under the BCDA store."""
exports_dir = store_path / "exports"
if not exports_dir.exists():
raise FileNotFoundError(
f"No exports directory at {exports_dir}. "
"Run a BCDA export first or specify --ndjson-dir."
)
# Export directories are named by job ID; pick the most recent
dirs = sorted(
[d for d in exports_dir.iterdir() if d.is_dir()],
key=lambda d: d.stat().st_mtime,
reverse=True,
)
if not dirs:
raise FileNotFoundError(f"No export directories in {exports_dir}")
return dirs[0]