feat(bcda): BCDA v3 — client, store adoption and cancel retries, dictionary-driven v3 flatteners, streaming multi-part load

Client defaults to /api/v3; _typeFilter on start_export/export with
type_filter_tag() for the System-Type tag; jobs() sends the bulk-data headers
BCDA requires (Prefer: respond-async, Accept: application/fhir+json — the old
call 400'd on v2 and v3) and unwraps the Task Bundle; completed_jobs() lists
reusable exports; every non-2xx raises BcdaHttpError carrying the
OperationOutcome diagnostics (still an httpx.HTTPStatusError); a poll that
hits 404 'Job has been cancelled' raises JobCancelledError; /_version and
/_health.

Store.adopt(client, job_url) records and downloads a job this store did not
start (completed jobs stay downloadable 24 h and the public sandbox entity is
shared); export(cancel_retries=N) restarts after server-side cancellation,
recording each 'cancelled' transition.

bcda.express.v3 flattens C4BB 2.2.0 resources into the existing bcda.* tables,
table-driven from the v3 Data Dictionary: v3 moved every BlueButton code off
resources/variables/<name> onto fhir/StructureDefinition|CodeSystem/<CODE> and
consolidated amounts into adjudication/total/item.adjudication, so the v2
suffix match returned NULL for almost everything. Dispatch is per resource
(is_v3); v2 payloads are unchanged. Identity follows the migration guide:
bene_id = Patient.id (BENE_SK), current MBI = us-mbi identifier without
period.end, clm_id = C4BB 'uc', clm_cntl_num pairs SharedSystem with NCH.
Race crosswalked from US Core OMB codes; Coverage is one row per plan+period
(Part A/B/C/D + Dual) with v3-native columns; EOB gains System-Type /
Final-Action / EOB-TYPE, IDR dates, header totals and PPS/IME/DSH amounts;
items gain NDC quantity, revenue-centre indicators, ANSI codes, Part D/DME
amounts.

flatten_export streams <Type>.N.ndjson parts to Parquet with model-derived
Arrow schemas (a Large-entity EOB export is 192 files / 2.2 GB); load_bcda()
gathers the newest export dir per resource type (a v3 pull is three
single-type jobs). cclf.stg_beneficiary_demographics prefers the Part A row
and joins the Dual row; eligibility reads coalesce(dual_01, dual_stus_cd).

bcda package at 100% line coverage; tests run on CMS's official v3 samples.

refs #1139
This commit is contained in:
kert
2026-10-25 13:08:13 -04:00
parent 7e53d9360a
commit d08401dc53
23 changed files with 4478 additions and 147 deletions

View File

@@ -10,8 +10,10 @@ Usage: stack load bcda [OPTIONS]
Flatten BCDA FHIR exports and load into DuckDB.
Reads Patient/EOB/Coverage ndjson files, flattens them to Parquet,
and loads into the ``bcda`` schema.
Reads Patient/EOB/Coverage ndjson files (v2 or v3 payloads, any
number of parts), flattens them to Parquet, and loads into the
``bcda`` schema. Without --ndjson-dir, uses the newest export per
resource type under the BCDA store (a v3 pull is three jobs).
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --ndjson-dir PATH Directory containing FHIR ndjson files. │

View File

@@ -28,8 +28,11 @@ def load_bcda(
Parameters
----------
ndjson_dir : str or Path, optional
Directory containing raw ndjson files. Defaults to the latest
export directory under ``conf.path("storage.bcda")``.
Directory containing raw ndjson files. Defaults to the newest
``exports/<key>/`` directory per resource type under
``conf.path("storage.bcda")`` — a v3 pull is usually three
single-type jobs, so Patient, Coverage and EOB may each come
from a different export directory.
database : str, optional
DuckDB database path. Defaults to ``conf.path("db.aco")``.
skip_flatten : bool
@@ -47,7 +50,7 @@ def load_bcda(
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
if ndjson_dir is None:
ndjson_dir = _find_latest_export(store_path)
if not skip_flatten:
@@ -89,20 +92,31 @@ def load_bcda(
return stats
def _find_latest_export(store_path: Path) -> Path:
"""Find the most recent export directory under the BCDA store."""
def _find_latest_export(store_path: Path) -> list[Path]:
"""Export directories holding the newest file of each resource type.
Oldest first, so :func:`bcda.express.flatten.resolve_ndjson`
(later directories win) picks the newest ``Patient.ndjson``,
``Coverage.ndjson`` and ``ExplanationOfBenefit.ndjson`` even when
they came from three separate single-type jobs.
"""
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
# Export directories are named by job key; order by mtime.
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]
newest: dict[str, Path] = {}
for d in dirs:
for rtype in ("Patient", "ExplanationOfBenefit", "Coverage"):
if (d / f"{rtype}.ndjson").exists():
newest[rtype] = d
chosen = sorted(set(newest.values()), key=lambda d: d.stat().st_mtime)
return chosen or [dirs[-1]]

View File

@@ -20,8 +20,11 @@ Submodules
----------
``bcda.client``
HTTP client for the BCDA v2 bulk FHIR export API.
Handles auth, job management, polling, and ndjson download.
HTTP client for the BCDA v3 bulk FHIR export API (``version="v2"``
still works for the legacy REACH sandbox entities). Handles auth,
``_typeFilter``, job management, polling, cancellation detection
and ndjson download; non-2xx responses carry the server's
OperationOutcome diagnostics.
``bcda.store``
Persistent storage and state management. Wraps the client
@@ -35,9 +38,15 @@ Submodules
append-only ``.jsonl`` file.
``bcda.express``
Narwhals expression functions for transforming BCDA FHIR R4
resources into input_layer tables (medical_claim,
pharmacy_claim, eligibility).
``flatten`` turns the raw ndjson into the ``bcda.*`` tables,
detecting the payload dialect per resource: the v2
(``resources/variables``) flattener in ``flatten`` itself, the v3
(C4BB 2.2.0, ``fhir/StructureDefinition``) flattener in
``bcda.express.v3`` — table-driven from the BCDA v3 Data
Dictionary (``dev/vendor/bcda-site``, ``dev/seeds/bcda_v3_*.csv``).
``cclf`` holds the Narwhals expression functions that transform
those tables into input_layer (medical_claim, pharmacy_claim,
eligibility).
``bcda.pipe``
Pipeline definitions wiring express functions into executable
@@ -52,16 +61,18 @@ Submodules
Adjudicated Claims — FHIR R4 Resources
----------------------------------------
Delivered via ``/api/v2/{Resource}/$export`` bulk endpoints:
Delivered via ``/api/{v2,v3}/{Resource}/$export`` bulk endpoints
(v3 since 2026-07-01; v1/v2 are discontinued on 2027-07-30):
::
| Table | Fields | FHIR R4 Resource |
|-------------------------|--------|------------------------|
| patient | 26 | Patient |
| explanation_of_benefit | 79 | ExplanationOfBenefit |
| eob_item | 23 | ExplanationOfBenefit |
| coverage | 10 | Coverage |
| Table | FHIR R4 Resource | v3 additions |
|-------------------------|------------------------|----------------------------------|
| patient | Patient | contact language, source keys |
| explanation_of_benefit | ExplanationOfBenefit | System-Type/Final-Action tags, |
| | | claim control number, IDR totals |
| eob_item | ExplanationOfBenefit | consolidated line adjudication |
| coverage | Coverage | one row per plan + period, Dual |
``ExplanationOfBenefit`` is split into header-level fields
(``explanation_of_benefit``) and repeating line items
@@ -70,9 +81,12 @@ Delivered via ``/api/v2/{Resource}/$export`` bulk endpoints:
Partially Adjudicated Claims
-----------------------------
Available via BCDA v2 only. Updated daily (2-4 day lag).
Sourced from FISS (institutional) and MCS (professional)
processing systems, mapped to FHIR R4 Claim and ClaimResponse:
In v3 these arrive as ``ExplanationOfBenefit`` tagged
``System-Type|SharedSystem`` (request them with ``_typeFilter``, see
``bcda.client.type_filter_tag``); the tables below are the v2
``Claim``/``ClaimResponse`` shape, kept for v2 exports. Updated
daily (2-4 day lag). Sourced from FISS (institutional) and MCS
(professional) processing systems:
::

View File

@@ -1,15 +1,23 @@
"""BCDA bulk FHIR export client.
Wraps the CMS Beneficiary Claims Data API v2 four-step flow:
Wraps the CMS Beneficiary Claims Data API v3 four-step flow:
1. Authenticate (POST ``/auth/token`` with client credentials)
2. Start export job (GET ``/api/v2/Group/all/$export``)
3. Poll until complete (GET ``/api/v2/jobs/{job_id}``)
2. Start export job (GET ``/api/v3/Group/all/$export``)
3. Poll until complete (GET ``/api/v3/jobs/{job_id}``)
4. Download ndjson files (GET ``/data/{job_id}/{filename}``)
Only the ``/api/<version>/`` segment differs between v2 and v3 —
``/auth`` and ``/data`` are unchanged (migration guide) — so
``version="v2"`` still works for the legacy REACH sandbox entities.
v3 adds ``_typeFilter`` (FHIR subquery, e.g. the ``System-Type``
``_tag`` that selects SharedSystem / NationalClaimsHistory / DDPS
claims); without it BCDA returns NationalClaimsHistory + DDPS only.
Handles token refresh (20-min expiry), exponential backoff on
transient errors, 429 rate-limit back-off, and gzip-compressed
ndjson downloads.
ndjson downloads. Every non-2xx response surfaces the server's
``OperationOutcome`` diagnostics (:class:`BcdaHttpError`).
Usage::
@@ -37,6 +45,18 @@ log = logging.getLogger(__name__)
SANDBOX = "https://sandbox.bcda.cms.gov"
PRODUCTION = "https://api.bcda.cms.gov"
DEFAULT_VERSION = "v3"
# FHIR Bulk Data headers BCDA requires on $export, job status and
# job history (both v2 and v3 reject /jobs without ``Prefer``).
FHIR_JSON = "application/fhir+json"
ASYNC_HEADERS = {"Accept": FHIR_JSON, "Prefer": "respond-async"}
# ``_typeFilter`` building blocks (migration guide, "Adjudication
# filtering"). ``type_filter_tag(*codes)`` renders the subquery; the
# client URL-encodes it.
SYSTEM_TYPE = "https://bluebutton.cms.gov/fhir/CodeSystem/System-Type"
SYSTEM_TYPES = ("SharedSystem", "NationalClaimsHistory", "DDPS")
# Defaults matching the Go reference implementation.
MAX_RETRIES = 3
@@ -52,6 +72,80 @@ class JobExpiredError(BcdaError):
"""Job has been archived (server returned 410 Gone)."""
class JobCancelledError(BcdaError):
"""Job status returned ``404 Job has been cancelled``.
Seen on the public sandbox, whose credentials are shared: any
caller can ``DELETE`` any job of the same model entity.
"""
class BcdaHttpError(BcdaError, httpx.HTTPStatusError):
"""A non-2xx response, carrying the ``OperationOutcome`` text.
Still an :class:`httpx.HTTPStatusError` so existing ``except``
clauses keep working; ``diagnostics`` holds the joined
``issue[].diagnostics`` strings (empty when the body was not an
OperationOutcome).
"""
def __init__(self, response: httpx.Response, diagnostics: str) -> None:
self.diagnostics = diagnostics
self.status_code = response.status_code
msg = f"{response.status_code} {response.reason_phrase} for {response.url}"
if diagnostics:
msg = f"{msg}: {diagnostics}"
httpx.HTTPStatusError.__init__(
self, msg, request=response.request, response=response
)
def operation_outcome(resp: httpx.Response) -> str:
"""Join the ``diagnostics`` of an OperationOutcome body, else ``""``."""
try:
body = resp.json()
except ValueError:
return ""
if not isinstance(body, dict) or body.get("resourceType") != "OperationOutcome":
return ""
parts = []
for issue in body.get("issue", []):
text = issue.get("diagnostics") or (issue.get("details") or {}).get("text")
if text:
parts.append(str(text))
return "; ".join(parts)
def raise_for_status(resp: httpx.Response) -> None:
"""``resp.raise_for_status()`` that keeps the server's diagnostics."""
if resp.is_success:
return
raise BcdaHttpError(resp, operation_outcome(resp))
def _requested_types(request: str) -> list[str]:
"""``_type`` values from a job's originating request line."""
from urllib.parse import parse_qs, unquote, urlsplit
url = request.split(" ", 1)[-1]
query = parse_qs(urlsplit(url).query)
raw = query.get("_type", [""])[0]
return [t for t in unquote(raw).split(",") if t]
def type_filter_tag(*codes: str) -> str:
"""Render a ``_typeFilter`` subquery selecting EOBs by System-Type.
``type_filter_tag("SharedSystem")`` → the v2 Claim/ClaimResponse
equivalent; ``type_filter_tag(*SYSTEM_TYPES)`` → everything.
"""
unknown = [c for c in codes if c not in SYSTEM_TYPES]
if unknown or not codes:
raise ValueError(f"System-Type codes must be among {SYSTEM_TYPES}: {codes}")
tags = ",".join(f"{SYSTEM_TYPE}|{c}" for c in codes)
return f"ExplanationOfBenefit?_tag={tags}"
class Client:
"""BCDA bulk FHIR export client.
@@ -67,7 +161,8 @@ class Client:
Directory for downloaded ndjson files.
Defaults to ``data/bcda``.
version : str
API version path segment. Defaults to ``v2``.
API version path segment. Defaults to ``v3``; ``"v2"`` for
the legacy REACH sandbox entities.
max_retries : int
Max retry attempts for transient errors.
retry_interval : float
@@ -83,7 +178,7 @@ class Client:
*,
base_url: str = SANDBOX,
output_dir: str | Path | None = None,
version: str = "v2",
version: str = DEFAULT_VERSION,
max_retries: int = MAX_RETRIES,
retry_interval: float = RETRY_INITIAL_INTERVAL,
timeout: float = 120.0,
@@ -133,7 +228,7 @@ class Client:
headers={"Accept": "application/json"},
content=b"",
)
resp.raise_for_status()
raise_for_status(resp)
data = resp.json()
self._token = data["access_token"]
expires_in = int(data.get("expires_in", TOKEN_LIFETIME))
@@ -269,7 +364,7 @@ class Client:
time.sleep(backoff)
backoff *= 2
continue
resp.raise_for_status()
raise_for_status(resp)
# 410 — job expired / archived.
if resp.status_code == 410:
@@ -278,8 +373,9 @@ class Client:
"Jobs expire 24 hours after completion."
)
# Any other client error — fail immediately.
resp.raise_for_status()
# Any other client error — fail immediately, with the
# server's OperationOutcome diagnostics in the message.
raise_for_status(resp)
return resp
# Should not reach here, but satisfy the type checker.
@@ -295,6 +391,7 @@ class Client:
endpoint: str = "Group/all",
types: list[str] | None = None,
since: str | None = None,
type_filter: str | None = None,
poll_interval: float = 10.0,
) -> list[Path]:
"""Run a full bulk export: start, poll, download.
@@ -311,6 +408,9 @@ class Client:
since : str | None
FHIR instant — only return resources updated after
this timestamp. Format: ``YYYY-MM-DDThh:mm:ss±zz:zz``.
type_filter : str | None
v3 ``_typeFilter`` subquery (not URL-encoded), e.g.
:func:`type_filter_tag`.
poll_interval : float
Seconds between status polls. Default 10.
@@ -323,6 +423,7 @@ class Client:
endpoint=endpoint,
types=types,
since=since,
type_filter=type_filter,
)
result = self.poll(job_url, interval=poll_interval)
return self.download(result)
@@ -333,6 +434,7 @@ class Client:
endpoint: str = "Group/all",
types: list[str] | None = None,
since: str | None = None,
type_filter: str | None = None,
) -> str:
"""Start a bulk export job.
@@ -347,14 +449,13 @@ class Client:
params["_type"] = ",".join(types)
if since:
params["_since"] = since
if type_filter:
params["_typeFilter"] = type_filter
resp = self._request(
"GET",
url,
headers={
"Accept": "application/fhir+json",
"Prefer": "respond-async",
},
headers=dict(ASYNC_HEADERS),
params=params,
)
@@ -392,14 +493,22 @@ class Client:
------
JobExpiredError
If the job has been archived (410 Gone).
JobCancelledError
If the server reports the job cancelled (404 with
``Job has been cancelled`` diagnostics).
BcdaError
If timeout is exceeded.
"""
deadline = (time.monotonic() + timeout) if timeout > 0 else 0
headers = {"Accept": "application/fhir+json"}
headers = dict(ASYNC_HEADERS)
while True:
resp = self._request("GET", job_url, headers=headers)
try:
resp = self._request("GET", job_url, headers=headers)
except BcdaHttpError as exc:
if exc.status_code == 404 and "cancel" in exc.diagnostics.lower():
raise JobCancelledError(f"{job_url}: {exc.diagnostics}") from exc
raise
if resp.status_code == 200:
result = resp.json()
@@ -423,7 +532,7 @@ class Client:
continue
# Shouldn't get here — _request handles other status codes.
resp.raise_for_status()
raise_for_status(resp)
def download(
self,
@@ -497,7 +606,9 @@ class Client:
"Accept-Encoding": "gzip",
}
with self._http.stream("GET", url, headers=headers) as resp:
resp.raise_for_status()
if not resp.is_success:
resp.read()
raise_for_status(resp)
content_encoding = resp.headers.get("Content-Encoding", "")
raw_bytes = b"".join(resp.iter_bytes())
@@ -539,14 +650,67 @@ class Client:
Returns
-------
list[dict]
Job history entries.
Job history entries. v3 (and v2) answer with a FHIR
``Bundle`` of ``Task`` resources — those resources are
returned; a bare list (v1) is passed through. Each Task
carries ``identifier[0].value`` (job id), ``status``,
``input[0].valueString`` (the originating request) and
``executionPeriod``.
"""
url = f"{self.base_url}/api/{self.version}/jobs"
params: dict[str, str] = {}
if status:
params["_status"] = status
resp = self._request("GET", url, params=params)
return resp.json()
# BCDA rejects /jobs without the bulk-data headers
# ("Prefer header is required" / "application/fhir+json is
# the only supported response format").
try:
resp = self._request("GET", url, headers=dict(ASYNC_HEADERS), params=params)
except BcdaHttpError as exc:
# bcda-app answers 404 when the filter matches nothing
# (api_test.go TestJobsStatusNotFoundWithStatus).
if exc.status_code == 404 and status:
return []
raise
body = resp.json()
if isinstance(body, dict) and body.get("resourceType") == "Bundle":
return [e["resource"] for e in body.get("entry", []) if "resource" in e]
return body
def completed_jobs(self) -> list[dict]:
"""Completed jobs, newest first, as ``(job_id, request, end)`` dicts.
Convenience over :meth:`jobs` for "reuse the last export"
logic: ``job_id`` is the numeric id, ``job_url`` the status
URL for :meth:`poll`, ``request`` the originating ``$export``
line, ``types`` the ``_type`` list it asked for, ``end`` the
completion instant. Only jobs started on this client's API
version are returned, since ``/data`` URLs from a different
version serve different payloads. Output files stay
downloadable for 24 hours after completion (then 410/404).
"""
out = []
# Filter client-side: the sandbox's ``_status=Completed`` answered
# 404 for jobs the unfiltered Bundle lists as ``completed``.
for task in self.jobs():
if str(task.get("status", "")).lower() != "completed":
continue
ident = (task.get("identifier") or [{}])[0]
job_id = ident.get("value")
request = ((task.get("input") or [{}])[0]).get("valueString", "")
if not job_id or f"/api/{self.version}/" not in request:
continue
out.append(
{
"job_id": job_id,
"job_url": f"{self.base_url}/api/{self.version}/jobs/{job_id}",
"request": request,
"types": _requested_types(request),
"end": (task.get("executionPeriod") or {}).get("end", ""),
}
)
out.sort(key=lambda j: j["end"], reverse=True)
return out
def attribution_status(self) -> dict:
"""Get last ingestion timestamps for attribution data.
@@ -572,6 +736,20 @@ class Client:
resp = self._request("GET", url, auth_required=False, retry=False)
return resp.json()
def server_version(self) -> dict:
"""GET ``/_version`` — the deployed bcda-app release (no auth)."""
resp = self._request(
"GET", f"{self.base_url}/_version", auth_required=False, retry=False
)
return resp.json()
def health(self) -> dict:
"""GET ``/_health`` — database liveness (no auth)."""
resp = self._request(
"GET", f"{self.base_url}/_health", auth_required=False, retry=False
)
return resp.json()
# ── Parsing helpers ──────────────────────────────────────
@staticmethod

View File

@@ -552,21 +552,44 @@ def stg_beneficiary_demographics(
Patient provides: name, dob, sex, race, death_dt, address
Coverage provides: ms_cd, orec, enrollment periods
Join on bene_mbi_id = subscriber_id. Coverage may have
multiple rows per beneficiary (Part A, B, D); we keep
the first match after dedup.
Join on bene_mbi_id = subscriber_id. Coverage has multiple rows
per beneficiary (Part A, B, D in v2; Part A, B, C, D and a Dual
Medicare/Medicaid row per period in v3). The Part A row is the
demographics row — it carries ms_cd / orec / entitlement — with
Part B as the fallback; the v3 Dual row is joined separately for
``dual_stus_cd`` (v2 keeps ``dual_01`` on the Part A row).
Operations: join, unique
Operations: filter, sort, unique, join
Dependencies: bcda.patient, bcda.coverage
"""
cov = bcda__coverage.unique(subset=["subscriber_id"], keep="any")
return bcda__patient.join(
cov,
cols = bcda__coverage.columns
parts = bcda__coverage
if "class_plan" in cols:
parts = parts.filter(nw.col("class_plan").is_in(["Part A", "Part B"])).sort(
"class_plan" # "Part A" < "Part B"
)
parts = parts.unique(subset=["subscriber_id"], keep="first", maintain_order=True)
joined = bcda__patient.join(
parts,
left_on="bene_mbi_id",
right_on="subscriber_id",
how="left",
suffix="_cov",
).unique(subset=["bene_mbi_id"], keep="any")
)
if "dual_stus_cd" in cols and "class_plan" in cols:
dual = (
bcda__coverage.filter(nw.col("class_plan") == "Dual Medicare/Medicaid")
.select("subscriber_id", nw.col("dual_stus_cd").alias("dual_stus_cd_dual"))
.unique(subset=["subscriber_id"], keep="any")
)
joined = joined.join(
dual,
left_on="bene_mbi_id",
right_on="subscriber_id",
how="left",
suffix="_dual",
)
return joined.unique(subset=["bene_mbi_id"], keep="any")
@nw.narwhalify
@@ -584,6 +607,12 @@ def eligibility(
"""
df = bcda___stg_beneficiary_demographics
null_str = nw.lit(None).cast(nw.String)
# v2 keeps dual status on the Part A row (dual_01); v3 carries it on
# the Dual Medicare/Medicaid row that stg_beneficiary_demographics
# joins in as dual_stus_cd_dual.
dual = nw.col("dual_01")
if "dual_stus_cd_dual" in df.columns:
dual = nw.coalesce(nw.col("dual_01"), nw.col("dual_stus_cd_dual"))
gender = (
nw.when(nw.col("bene_sex_cd") == "1")
@@ -641,7 +670,7 @@ def eligibility(
nw.lit("medicare").alias("payer_type"),
nw.lit("medicare").alias("plan"),
nw.col("orec").alias("original_reason_entitlement_code"),
nw.col("dual_01").alias("dual_status_code"),
dual.alias("dual_status_code"),
nw.col("ms_cd").alias("medicare_status_code"),
null_str.alias("group_id"),
null_str.alias("group_name"),

View File

@@ -35,6 +35,7 @@ from pathlib import Path
import pyarrow as pa
import pyarrow.parquet as pq
from bcda.express import v3
from bcda.table.coverage import Coverage
from bcda.table.eob import EobItem, ExplanationOfBenefit
from bcda.table.patient import Patient
@@ -228,7 +229,13 @@ def _coding_code(
def flatten_patient(resource: dict) -> Patient:
"""Flatten a raw FHIR R4 Patient resource into a Patient model."""
"""Flatten a raw FHIR R4 Patient resource into a Patient model.
v3 (C4BB 2.2.0) resources are routed to :mod:`bcda.express.v3`;
the body below is the v2 (``resources/variables``) dialect.
"""
if v3.is_v3(resource):
return v3.flatten_patient(resource)
extensions = resource.get("extension", [])
identifiers = resource.get("identifier", [])
address = (resource.get("address") or [{}])[0]
@@ -548,7 +555,12 @@ def _extract_item(
def flatten_eob(
resource: dict,
) -> tuple[ExplanationOfBenefit, list[EobItem]]:
"""Flatten a raw FHIR R4 EOB into header + item models."""
"""Flatten a raw FHIR R4 EOB into header + item models.
v3 resources are routed to :mod:`bcda.express.v3`.
"""
if v3.is_v3(resource):
return v3.flatten_eob(resource)
extensions = resource.get("extension", [])
identifiers = resource.get("identifier", [])
billable = resource.get("billablePeriod", {})
@@ -801,7 +813,12 @@ def flatten_eob(
def flatten_coverage(resource: dict) -> Coverage:
"""Flatten a raw FHIR R4 Coverage resource into a Coverage model."""
"""Flatten a raw FHIR R4 Coverage resource into a Coverage model.
v3 resources are routed to :mod:`bcda.express.v3`.
"""
if v3.is_v3(resource):
return v3.flatten_coverage(resource)
extensions = resource.get("extension", [])
# Class discrimination
@@ -940,6 +957,66 @@ def _read_ndjson(path: Path) -> list[dict]:
return [json.loads(line) for line in f if line.strip()]
def _iter_ndjson(paths: list[Path]):
"""Yield resources from several ndjson parts, one line at a time."""
for path in paths:
with open(path) as f:
for line in f:
if line.strip():
yield json.loads(line)
_ARROW_TYPES = {
"str": pa.string(),
"float": pa.float64(),
"int": pa.int64(),
"bool": pa.bool_(),
"date": pa.date32(),
"datetime": pa.timestamp("us"),
}
def arrow_schema(model: type) -> pa.Schema:
"""Arrow schema for a ``SQLTable`` model (every column nullable).
Streaming writes need a fixed schema up front — inferring it from
the first batch would type an all-NULL column as ``null`` and then
reject a later batch that carries values.
"""
import types as _types
fields = []
for name, info in model.model_fields.items():
ann = info.annotation
if isinstance(ann, _types.UnionType):
ann = next(a for a in ann.__args__ if a is not type(None))
key = getattr(ann, "__name__", str(ann))
fields.append(pa.field(name, _ARROW_TYPES.get(key, pa.string())))
return pa.schema(fields)
class _ParquetSink:
"""Append-by-batch Parquet writer over fsspec; counts rows."""
def __init__(self, fs: object, path: str, schema: pa.Schema) -> None:
parent = path.rsplit("/", 1)[0]
fs.mkdirs(parent, exist_ok=True)
self._fh = fs.open(path, "wb")
self._writer = pq.ParquetWriter(self._fh, schema)
self._schema = schema
self.rows = 0
def write(self, rows: list[dict]) -> None:
if not rows:
return
self._writer.write_table(pa.Table.from_pylist(rows, schema=self._schema))
self.rows += len(rows)
def close(self) -> None:
self._writer.close()
self._fh.close()
def _write_parquet(
fs: object,
path: str,
@@ -956,8 +1033,48 @@ def _write_parquet(
return table.num_rows
RESOURCE_TYPES = ("Patient", "ExplanationOfBenefit", "Coverage")
# Resources per Arrow batch while streaming an export to Parquet. A
# Large-entity v3 EOB export is ~190 files / 2 GB of ndjson, so the
# flattener never holds a whole resource type in memory.
BATCH_ROWS = 2000
def resolve_ndjson(ndjson_dir: str | Path | list[str | Path]) -> dict[str, list[Path]]:
"""``{resource_type: [part, ...]}`` for the export files.
*ndjson_dir* may be one directory or several (a v3 pull is often
three single-type jobs, each landing in its own ``exports/<key>/``
directory — see ``aco.load.bcda``). Later directories win. A
type's parts are ``<Type>.ndjson``, ``<Type>.1.ndjson``, … as the
client and store name them (BCDA splits large exports into many
output files); they are returned in part order.
"""
dirs = [ndjson_dir] if isinstance(ndjson_dir, (str, Path)) else list(ndjson_dir)
found: dict[str, list[Path]] = {}
for d in dirs:
for rtype in RESOURCE_TYPES:
parts = sorted(
Path(d).glob(f"{rtype}*.ndjson"),
key=lambda p: (
int(p.name.split(".")[1])
if p.name.count(".") == 2 and p.name.split(".")[1].isdigit()
else -1
),
)
parts = [
p
for p in parts
if p.name == f"{rtype}.ndjson" or p.name.split(".")[1].isdigit()
]
if parts:
found[rtype] = parts
return found
def flatten_export(
ndjson_dir: str | Path,
ndjson_dir: str | Path | list[str | Path],
store_path: str | Path | None = None,
*,
storage_options: dict | None = None,
@@ -965,9 +1082,10 @@ def flatten_export(
"""Flatten raw FHIR ndjson files and write to Parquet.
Reads ``Patient.ndjson``, ``ExplanationOfBenefit.ndjson``, and
``Coverage.ndjson`` from *ndjson_dir*, flattens them into the
``bcda.table`` Pydantic models, and persists each as a Parquet
file under *store_path*.
``Coverage.ndjson`` from *ndjson_dir* (one directory or a list —
see :func:`resolve_ndjson`), flattens them into the ``bcda.table``
Pydantic models (v2 or v3 dialect per resource), and persists each
as a Parquet file under *store_path*.
Output layout::
@@ -998,85 +1116,88 @@ def flatten_export(
store_path = path("storage.bcda")
setup()
ndjson_dir = Path(ndjson_dir)
files = resolve_ndjson(ndjson_dir)
root = str(store_path).rstrip("/")
fs, _ = fsspec.core.url_to_fs(root, **(storage_options or {}))
counts: dict[str, int] = {}
# Patient
patient_path = ndjson_dir / "Patient.ndjson"
if patient_path.exists():
if files.get("Patient"):
t0 = time.monotonic()
raw = _read_ndjson(patient_path)
patients = flatten_patients(raw)
rows = [p.model_dump() for p in patients]
n = _write_parquet(fs, f"{root}/flat/patient.parquet", rows)
elapsed = time.monotonic() - t0
counts["bcda.patient"] = n
sink = _ParquetSink(fs, f"{root}/flat/patient.parquet", arrow_schema(Patient))
batch: list[dict] = []
for raw in _iter_ndjson(files["Patient"]):
batch.append(flatten_patient(raw).model_dump())
if len(batch) >= BATCH_ROWS:
sink.write(batch)
batch = []
sink.write(batch)
sink.close()
counts["bcda.patient"] = sink.rows
log.info(
"Flattened %d patients in %.1fs",
n,
elapsed,
extra={"table": "bcda.patient", "rows": n},
sink.rows,
time.monotonic() - t0,
extra={"table": "bcda.patient", "rows": sink.rows},
)
# ExplanationOfBenefit + EobItem
eob_path = ndjson_dir / "ExplanationOfBenefit.ndjson"
if eob_path.exists():
if files.get("ExplanationOfBenefit"):
t0 = time.monotonic()
raw = _read_ndjson(eob_path)
headers, items = flatten_eobs(raw)
header_rows = [h.model_dump() for h in headers]
item_rows = [i.model_dump() for i in items]
n_headers = _write_parquet(
head_sink = _ParquetSink(
fs,
f"{root}/flat/explanation_of_benefit.parquet",
header_rows,
arrow_schema(ExplanationOfBenefit),
)
n_items = _write_parquet(
fs,
f"{root}/flat/eob_item.parquet",
item_rows,
item_sink = _ParquetSink(
fs, f"{root}/flat/eob_item.parquet", arrow_schema(EobItem)
)
elapsed = time.monotonic() - t0
counts["bcda.explanation_of_benefit"] = n_headers
counts["bcda.eob_item"] = n_items
heads: list[dict] = []
items: list[dict] = []
for raw in _iter_ndjson(files["ExplanationOfBenefit"]):
header, lines = flatten_eob(raw)
heads.append(header.model_dump())
items.extend(i.model_dump() for i in lines)
if len(heads) >= BATCH_ROWS:
head_sink.write(heads)
item_sink.write(items)
heads, items = [], []
head_sink.write(heads)
item_sink.write(items)
head_sink.close()
item_sink.close()
counts["bcda.explanation_of_benefit"] = head_sink.rows
counts["bcda.eob_item"] = item_sink.rows
log.info(
"Flattened %d EOBs (%d items) in %.1fs",
n_headers,
n_items,
elapsed,
head_sink.rows,
item_sink.rows,
time.monotonic() - t0,
extra={
"table": "bcda.explanation_of_benefit",
"rows": n_headers,
"items": n_items,
"rows": head_sink.rows,
"items": item_sink.rows,
},
)
# Coverage
coverage_path = ndjson_dir / "Coverage.ndjson"
if coverage_path.exists():
if files.get("Coverage"):
t0 = time.monotonic()
raw = _read_ndjson(coverage_path)
coverages = flatten_coverages(raw)
rows = [c.model_dump() for c in coverages]
n = _write_parquet(
fs,
f"{root}/flat/coverage.parquet",
rows,
)
elapsed = time.monotonic() - t0
counts["bcda.coverage"] = n
sink = _ParquetSink(fs, f"{root}/flat/coverage.parquet", arrow_schema(Coverage))
batch = []
for raw in _iter_ndjson(files["Coverage"]):
batch.append(flatten_coverage(raw).model_dump())
if len(batch) >= BATCH_ROWS:
sink.write(batch)
batch = []
sink.write(batch)
sink.close()
counts["bcda.coverage"] = sink.rows
log.info(
"Flattened %d coverage records in %.1fs",
n,
elapsed,
extra={"table": "bcda.coverage", "rows": n},
"Flattened %d coverages in %.1fs",
sink.rows,
time.monotonic() - t0,
extra={"table": "bcda.coverage", "rows": sink.rows},
)
log.info(
"Flatten complete: %s",
counts,
extra={"counts": counts},
)
return counts

1212
src/bcda/express/v3.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ Pipeline expressions (12 total)::
stg_beneficiary_demographics Patient + Coverage
eligibility demographics → eligibility
Source data — BCDA v2 bulk FHIR export:
Source data — BCDA bulk FHIR export (v2 or v3, flattened to bcda.*):
- ExplanationOfBenefit (header + items)
- Patient
- Coverage

View File

@@ -19,7 +19,7 @@ the appropriate extra (``s3fs``, ``adlfs``) installed by the user.
Usage::
from bcda.client import Client
from bcda.client import Client, JobCancelledError
from bcda.store import Store
store = Store()
@@ -51,7 +51,7 @@ import fsspec
import pyarrow as pa
import pyarrow.parquet as pq
from bcda.client import Client
from bcda.client import Client, JobCancelledError
log = logging.getLogger(__name__)
@@ -264,7 +264,9 @@ class Store:
endpoint: str = "Group/all",
types: list[str] | None = None,
since: str | None = None,
type_filter: str | None = None,
poll_interval: float = 10.0,
cancel_retries: int = 0,
) -> str:
"""Run a full export with persistent state tracking.
@@ -283,11 +285,22 @@ class Store:
FHIR resource types to request.
since : str | None
``_since`` timestamp filter.
type_filter : str | None
v3 ``_typeFilter`` subquery (recorded alongside ``types``).
poll_interval : float
Seconds between status polls.
cancel_retries : int
How many times to restart the export after the server
reports it cancelled. On the public sandbox every caller
of a model entity can ``DELETE`` any job, and sweeps that
cancel every in-progress job happen several times an hour;
each cancellation is recorded as a ``cancelled`` transition
under the same key before the next attempt.
"""
key = _generate_key()
types_str = ",".join(types) if types else ""
if type_filter:
types_str = f"{types_str}|_typeFilter={type_filter}"
log.info("Starting export %s", key, extra={"job_key": key})
# 1. Record pending.
@@ -300,23 +313,47 @@ class Store:
)
try:
# 2. Start export.
job_url = client.start_export(
endpoint=endpoint,
types=types,
since=since,
)
self._append_job(
key=key,
job_url=job_url,
endpoint=endpoint,
types=types_str,
since=since or "",
status="polling",
)
for attempt in range(cancel_retries + 1):
# 2. Start export.
job_url = client.start_export(
endpoint=endpoint,
types=types,
since=since,
type_filter=type_filter,
)
self._append_job(
key=key,
job_url=job_url,
endpoint=endpoint,
types=types_str,
since=since or "",
status="polling",
)
# 3. Poll until complete.
result = client.poll(job_url, interval=poll_interval)
# 3. Poll until complete.
try:
result = client.poll(job_url, interval=poll_interval)
break
except JobCancelledError as exc:
self._append_job(
key=key,
job_url=job_url,
endpoint=endpoint,
types=types_str,
since=since or "",
status="cancelled",
error=str(exc),
)
if attempt >= cancel_retries:
raise
log.warning(
"Export %s cancelled server-side (%s); restarting (%d/%d)",
key,
job_url,
attempt + 1,
cancel_retries,
extra={"job_key": key},
)
self._append_job(
key=key,
job_url=job_url,
@@ -349,6 +386,60 @@ class Store:
log.info("Export %s finished", key, extra={"job_key": key})
return key
def adopt(
self,
client: Client,
job_url: str,
*,
endpoint: str = "Group/all",
types: list[str] | None = None,
poll_interval: float = 10.0,
) -> str:
"""Record and download a job this store did not start.
BCDA keeps a completed job's files for 24 hours, and on the
public sandbox every caller of a model entity sees the same
job list — so a fresh export (6 min and 2 GB for the Large
entity's EOBs) is wasted when ``client.completed_jobs()``
already lists one. ``adopt`` polls *job_url* (returns at once
when complete), appends the job under a new key with status
``complete`` and downloads its outputs exactly as
:meth:`export` would. Returns the job key.
"""
key = _generate_key()
types_str = ",".join(types) if types else ""
log.info("Adopting job %s as %s", job_url, key, extra={"job_key": key})
self._append_job(
key=key,
job_url=job_url,
endpoint=endpoint,
types=types_str,
status="polling",
)
try:
result = client.poll(job_url, interval=poll_interval)
self._append_job(
key=key,
job_url=job_url,
endpoint=endpoint,
types=types_str,
status="complete",
)
self._download_outputs(client, key, result)
except Exception as exc:
log.error("Adopt %s failed: %s", key, exc, extra={"job_key": key})
self._append_job(
key=key,
job_url=job_url,
endpoint=endpoint,
types=types_str,
status="failed",
error=str(exc),
)
raise
log.info("Adopted %s finished", key, extra={"job_key": key})
return key
def _download_outputs(
self,
client: Client,

View File

@@ -1,6 +1,6 @@
"""FHIR R4 Coverage resource — beneficiary enrollment and entitlement.
Flattened from the BCDA v2 Coverage ndjson export. Each beneficiary
Flattened from the BCDA Coverage ndjson export (v2 or v3). Each beneficiary
may have multiple Coverage records (Part A, Part B, Part D).
The Coverage resource carries enrollment status, dual eligibility,
@@ -9,7 +9,14 @@ extensions for buy-in, HMO, cost sharing, Part C/D contracts, and
Retiree Drug Subsidy. Each monthly series has 12 fields (01–12)
representing January through December of the reference year.
Source: ``/api/v2/Coverage/$export`` bulk FHIR endpoint.
v3 delivers one Coverage per plan per period (``class_plan`` ∈ Part A,
Part B, Part C, Part D, Dual Medicare/Medicaid) with ``Coverage.period``
instead of the v2 reference-year monthly series; the v3 extensions land
in the ``*_cd`` columns below and the legacy part A/B/D begin/end dates
are filled from the matching plan's period so downstream joins keep
working.
Source: ``/api/{v2,v3}/Coverage/$export`` bulk FHIR endpoint.
"""
from __future__ import annotations
@@ -563,3 +570,74 @@ class Coverage(SQLTable):
ma_12: str | None = None
"""Medicare Advantage indicator — December."""
# ── v3-only (BCDA v3 Data Dictionary) ────────────────────
period_start: date | None = None
"""Coverage.period.start — enrollment / eligibility begin for this
plan row (IDR BENE_RNG_BGN_DT, BENE_ENRLMT_BGN_DT, BENE_MDCD_ELGBLTY_BGN_DT)."""
period_end: date | None = None
"""Coverage.period.end (IDR BENE_RNG_END_DT, BENE_ENRLMT_END_DT,
BENE_MDCD_ELGBLTY_END_DT)."""
buyin_cd: str | None = None
"""Buy-in code (extension BENE-BUYIN-CD; CCW MDCR_ENTLMT_BUYIN_*_IND)."""
entlmt_stus_cd: str | None = None
"""Medicare entitlement status (BENE-MDCR-ENTLMT-STUS-CD; CCW
BENE_PTA/PTB_TRMNTN_CD — also copied to ``a_trm_cd``/``b_trm_cd``)."""
enrlmt_rsn_cd: str | None = None
"""Enrollment reason (BENE-ENRLMT-RSN-CD)."""
dsbld_stus_id: str | None = None
"""Disabled status (BENE-DSBLD-STUS-ID)."""
dual_stus_cd: str | None = None
"""Dual status (BENE-DUAL-STUS-CD) on the Dual Medicare/Medicaid
row; CCW DUAL_STUS_CD_01..12."""
dual_type_cd: str | None = None
"""Medicare/Medicaid dual type (BENE-DUAL-TYPE-CD)."""
medicaid_state_cd: str | None = None
"""Medicaid state, USPS (MEDICAID-STATE-CD)."""
cntrct_num: str | None = None
"""Part C/D contract number (BENE-CNTRCT-NUM; CCW PTC/PTD_CNTRCT_ID_*)."""
pbp_num: str | None = None
"""Part C/D plan benefit package (BENE-PBP-NUM; CCW PTC/PTD_PBP_ID_*)."""
cvrg_type_cd: str | None = None
"""Part C/D coverage type (BENE-CVRG-TYPE-CD; CCW PTC_PLAN_TYPE_CD_*)."""
pbp_sgmt_num: str | None = None
"""Part D PBP segment (CNTRCT-PBP-SGMT-NUM; CCW PTD_SGMT_ID_*)."""
lis_efctv_cd: str | None = None
"""Part D LIS effective code (BENE-LIS-EFCTV-CD; CCW CST_SHR_GRP_CD_*)."""
lis_copmt_lvl_cd: str | None = None
"""Part D LIS copayment level (BENE-LIS-COPMT-LVL-CD)."""
lis_ptd_prm_pct: str | None = None
"""Part D LIS premium subsidy percentage (BENE-LIS-PTD-PRM-PCT)."""
rds_sw: str | None = None
"""Retiree drug subsidy switch (BENE-ENRLMT-EMPLR-SBSDY-SW; CCW RDS_IND_*)."""
rx_id: str | None = None
"""Part D PDP member id (Coverage.class[rxid])."""
rx_group: str | None = None
"""Part D PDP group (Coverage.class[rxgroup])."""
rx_pcn: str | None = None
"""Part D PDP processor control number (Coverage.class[rxpcn])."""
rx_bin: str | None = None
"""Part D PDP bank id (Coverage.class[rxbin])."""
cntrct_pbp_name: str | None = None
"""Part C/D contract plan name (contained Organization.name)."""

View File

@@ -1,6 +1,6 @@
"""FHIR R4 ExplanationOfBenefit resource — adjudicated claims.
Flattened from the BCDA v2 ExplanationOfBenefit ndjson export.
Flattened from the BCDA ExplanationOfBenefit ndjson export (v2 or v3).
Covers Part A (institutional), Part B (professional/DME), and
Part D (pharmacy) claims in a single resource type.
@@ -10,7 +10,14 @@ fields (line items with revenue codes, HCPCS, adjudication amounts).
This model represents the header-level flattened fields; item-level
arrays are modeled separately in ``EobItem``.
Source: ``/api/v2/ExplanationOfBenefit/$export`` bulk FHIR endpoint.
v3 represents partially *and* fully adjudicated claims as EOBs (the
``system_type`` tag says which: SharedSystem, NationalClaimsHistory,
DDPS) and consolidates the v2 benefitBalance/extension amounts into
``adjudication``/``total``/``item.adjudication`` under the
``https://bluebutton.cms.gov/fhir/CodeSystem/Adjudication`` system.
The v3-only columns below carry those amounts under their IDR names.
Source: ``/api/{v2,v3}/ExplanationOfBenefit/$export`` bulk FHIR endpoint.
"""
from __future__ import annotations
@@ -360,6 +367,244 @@ class ExplanationOfBenefit(SQLTable):
"""Pharmacy service type code
(Eob.facility extension valueCoding)."""
# ── v3-only: identity and provenance (BCDA v3 Data Dictionary) ──
clm_uniq_id: str | None = None
"""IDR claim surrogate key (identifier[C4BB uc]; IDR CLM_UNIQ_ID).
Same value as ``clm_id`` on v3 rows."""
clm_orig_cntl_num: str | None = None
"""Original claim control number for adjusted claims
(related[].reference where system CLM-CNTL-NUM)."""
system_type: str | None = None
"""meta.tag System-Type: SharedSystem (partially adjudicated),
NationalClaimsHistory, DDPS."""
final_action: str | None = None
"""meta.tag Final-Action: FinalAction / NotFinalAction."""
eob_type: str | None = None
"""BlueButton EOB-TYPE coding: INPATIENT, OUTPATIENT, SNF, HHA,
HOSPICE, CARRIER, DME, PDE."""
clm_src_id: str | None = None
"""meta.source (IDR CLM_SRC_ID / META_SRC_SK): NCH, DDPS, FISS, MCS, VMS."""
outcome: str | None = None
"""ExplanationOfBenefit.outcome: complete / partial."""
clm_adjstmt_type_cd: str | None = None
"""Claim adjustment type (supportingInfo CLM_ADJSTMT_TYPE_CD)."""
clm_idr_ld_dt: date | None = None
"""IDR load date (supportingInfo CLM_IDR_LD_DT)."""
clm_cms_proc_dt: date | None = None
"""CMS process date (supportingInfo CLM_CMS_PROC_DT)."""
clm_nch_wkly_proc_dt: date | None = None
"""NCH weekly process date (supportingInfo CLM_NCH_WKLY_PROC_DT)."""
clm_submsn_dt: date | None = None
"""Claim received date (supportingInfo clmrecvddate)."""
clm_pd_dt: date | None = None
"""Payment date (payment.date; IDR CLM_PD_DT)."""
clm_sbmt_frmt_cd: str | None = None
"""Submission format (supportingInfo CLM_SBMT_FRMT_CD)."""
clm_blg_prvdr_name: str | None = None
"""Billing provider name (contained Organization.name)."""
clm_blg_prvdr_tax_num: str | None = None
"""Billing provider tax number (contained Organization identifier
urn:oid:2.16.840.1.113883.4.4)."""
clm_blg_prvdr_zip5_cd: str | None = None
"""Billing provider ZIP (contained Organization.address.postalCode)."""
clm_dschrg_dt: date | None = None
"""Discharge date (supportingInfo admissionperiod end)."""
clm_actv_care_thru_dt: date | None = None
"""Covered care through date (supportingInfo CLM_ACTV_CARE_THRU_DT)."""
clm_mdcr_exhstd_dt: date | None = None
"""Medicare benefits exhausted date (supportingInfo CLM_MDCR_EXHSTD_DT)."""
clm_ncvrd_from_dt: date | None = None
"""Noncovered stay from (supportingInfo CLM_NCVRD_FROM_DT)."""
clm_ncvrd_thru_dt: date | None = None
"""Noncovered stay through (supportingInfo CLM_NCVRD_THRU_DT)."""
clm_qlfy_stay_from_dt: date | None = None
"""Qualified stay from (supportingInfo CLM_QLFY_STAY_FROM_DT)."""
clm_qlfy_stay_thru_dt: date | None = None
"""Qualified stay through (supportingInfo CLM_QLFY_STAY_THRU_DT)."""
clm_pps_ind_cd: str | None = None
"""PPS indicator (supportingInfo CLM_PPS_IND_CD)."""
clm_hha_lup_ind_cd: str | None = None
"""HHA LUPA indicator (supportingInfo CLM_HHA_LUP_IND_CD)."""
clm_hha_rfrl_cd: str | None = None
"""HHA referral code (supportingInfo CLM_HHA_RFRL_CD)."""
clm_clncl_tril_num: str | None = None
"""Clinical trial number (supportingInfo CLM_CLNCL_TRIL_NUM)."""
clm_prcng_excptn_cd: str | None = None
"""Pricing exception / benefit payment status reason
(adjudication benefitpaymentstatus reason)."""
# ── v3-only: header totals (ExplanationOfBenefit.total) ──
clm_sbmt_chrg_amt: float | None = None
"""Total submitted charge (total CLM_SBMT_CHRG_AMT)."""
clm_alowd_chrg_amt: float | None = None
"""Allowed charge (total CLM_ALOWD_CHRG_AMT)."""
clm_bene_pmt_amt: float | None = None
"""Paid by beneficiary (total CLM_BENE_PMT_AMT)."""
clm_prvdr_pmt_amt: float | None = None
"""Paid to provider (total CLM_PRVDR_PMT_AMT)."""
clm_mdcr_ddctbl_amt: float | None = None
"""Deductible (total CLM_MDCR_DDCTBL_AMT)."""
clm_mdcr_coinsrnc_amt: float | None = None
"""Coinsurance liability (total CLM_MDCR_COINSRNC_AMT)."""
clm_ncvrd_chrg_amt: float | None = None
"""Noncovered charge (total CLM_NCVRD_CHRG_AMT)."""
clm_othr_tp_pd_amt: float | None = None
"""Other third party paid (total CLM_OTHR_TP_PD_AMT)."""
clm_mdcr_instnl_bene_pd_amt: float | None = None
"""Paid to patient, institutional (total CLM_MDCR_INSTNL_BENE_PD_AMT)."""
tot_rx_cst_amt: float | None = None
"""Total drug cost, Part D (total TOT_RX_CST_AMT)."""
# ── v3-only: header adjudication (ExplanationOfBenefit.adjudication) ──
clm_instnl_cvrd_day_cnt: float | None = None
"""Utilization day count (CLM_INSTNL_CVRD_DAY_CNT)."""
clm_mdcr_ip_lrd_use_cnt: float | None = None
"""Lifetime reserve days used (CLM_MDCR_IP_LRD_USE_CNT)."""
clm_instnl_mdcr_coins_day_cnt: float | None = None
"""Coinsurance day count (CLM_INSTNL_MDCR_COINS_DAY_CNT)."""
clm_instnl_ncvrd_day_cnt: float | None = None
"""Non-utilization day count (CLM_INSTNL_NCVRD_DAY_CNT)."""
clm_instnl_per_diem_amt: float | None = None
"""Pass-through per diem (CLM_INSTNL_PER_DIEM_AMT)."""
clm_mdcr_ip_pps_drg_wt_num: float | None = None
"""PPS capital DRG weight (CLM_MDCR_IP_PPS_DRG_WT_NUM)."""
clm_mdcr_ip_pps_cptl_fsp_amt: float | None = None
"""PPS capital FSP amount (CLM_MDCR_IP_PPS_CPTL_FSP_AMT)."""
clm_mdcr_ip_pps_outlier_amt: float | None = None
"""PPS capital outlier amount (CLM_MDCR_IP_PPS_OUTLIER_AMT)."""
clm_mdcr_ip_pps_excptn_amt: float | None = None
"""PPS capital exception amount (CLM_MDCR_IP_PPS_EXCPTN_AMT)."""
clm_mdcr_ip_pps_cptl_hrmls_amt: float | None = None
"""PPS old capital hold harmless (CLM_MDCR_IP_PPS_CPTL_HRMLS_AMT)."""
clm_mdcr_ip_pps_cptl_tot_amt: float | None = None
"""PPS total capital (CLM_MDCR_IP_PPS_CPTL_TOT_AMT)."""
clm_instnl_drg_outlier_amt: float | None = None
"""DRG outlier approved payment (CLM_INSTNL_DRG_OUTLIER_AMT)."""
clm_mdcr_instnl_prmry_pyr_amt: float | None = None
"""Institutional primary payer paid (CLM_MDCR_INSTNL_PRMRY_PYR_AMT)."""
clm_mdcr_prfnl_prmry_pyr_amt: float | None = None
"""Carrier primary payer paid (CLM_MDCR_PRFNL_PRMRY_PYR_AMT)."""
clm_instnl_prfnl_amt: float | None = None
"""Professional component charge (CLM_INSTNL_PRFNL_AMT)."""
clm_blood_lblty_amt: float | None = None
"""Blood deductible liability (CLM_BLOOD_LBLTY_AMT)."""
clm_mdcr_ip_bene_ddctbl_amt: float | None = None
"""Inpatient deductible (CLM_MDCR_IP_BENE_DDCTBL_AMT)."""
clm_blood_chrg_amt: float | None = None
"""Blood charge (CLM_BLOOD_CHRG_AMT)."""
clm_blood_ncvrd_chrg_amt: float | None = None
"""Blood noncovered charge (CLM_BLOOD_NCVRD_CHRG_AMT)."""
clm_mdcr_hospc_prd_cnt: float | None = None
"""Hospice period count (CLM_MDCR_HOSPC_PRD_CNT)."""
clm_mdcr_hha_tot_visit_cnt: float | None = None
"""HHA total visit count (CLM_MDCR_HHA_TOT_VISIT_CNT)."""
clm_finl_stdzd_pymt_amt: float | None = None
"""Final standardized payment (CLM_FINL_STDZD_PYMT_AMT)."""
clm_hac_rdctn_pymt_amt: float | None = None
"""HAC reduction (CLM_HAC_RDCTN_PYMT_AMT)."""
clm_hipps_readmsn_rdctn_amt: float | None = None
"""Readmission reduction (CLM_HIPPS_READMSN_RDCTN_AMT)."""
clm_hipps_vbp_amt: float | None = None
"""Value-based purchasing (CLM_HIPPS_VBP_AMT)."""
clm_instnl_low_vol_pmt_amt: float | None = None
"""Low volume payment (CLM_INSTNL_LOW_VOL_PMT_AMT)."""
# ── v3-only: Part D supporting info ──
clm_brnd_gnrc_cd: str | None = None
"""Brand/generic (supportingInfo brandgenericindicator)."""
clm_cmpnd_cd: str | None = None
"""Compound code (supportingInfo compoundcode)."""
clm_line_rx_orgn_cd: str | None = None
"""Rx origin (supportingInfo rxorigincode)."""
clm_ptnt_rsdnc_cd: str | None = None
"""Patient residence (supportingInfo CLM_PTNT_RSDNC_CD)."""
clm_ltc_dspnsng_mthd_cd: str | None = None
"""LTC dispensing method (supportingInfo CLM_LTC_DSPNSNG_MTHD_CD)."""
clm_drug_cvrg_stus_cd: str | None = None
"""Drug coverage status (supportingInfo CLM_DRUG_CVRG_STUS_CD)."""
clm_ctstrphc_cvrg_ind_cd: str | None = None
"""Catastrophic coverage indicator (supportingInfo CLM_CTSTRPHC_CVRG_IND_CD)."""
clm_dspnsng_stus_cd: str | None = None
"""Dispensing status (supportingInfo CLM_DSPNSNG_STUS_CD)."""
clm_sbmtr_cntrct_num: str | None = None
"""Submitter contract (supportingInfo CLM_SBMTR_CNTRCT_NUM)."""
clm_sbmtr_cntrct_pbp_num: str | None = None
"""Submitter PBP (supportingInfo CLM_SBMTR_CNTRCT_PBP_NUM)."""
clm_line_authrzd_fill_num: float | None = None
"""Authorized refills (supportingInfo refillsauthorized)."""
class EobItem(SQLTable):
"""FHIR R4 ExplanationOfBenefit.item — claim line items.
@@ -571,3 +816,119 @@ class EobItem(SQLTable):
tax_num: str | None = None
"""Line-level provider tax number
(Eob.item.extension[tax_num] valueCoding)."""
# ── v3-only (BCDA v3 Data Dictionary, item level) ────────
clm_line_ndc_qty: float | None = None
"""NDC quantity (item/detail quantity.value; IDR CLM_LINE_NDC_QTY)."""
clm_line_ndc_qty_qlfyr_cd: str | None = None
"""NDC quantity unit (item/detail quantity.unit)."""
clm_line_dgns_cd: str | None = None
"""Line diagnosis — header diagnosis at item.diagnosisSequence[0]."""
clm_rev_dscnt_ind_cd: str | None = None
"""Revenue center discount indicator (extension CLM-REV-DSCNT-IND-CD)."""
clm_otaf_one_ind_cd: str | None = None
"""Obligation to accept as full payment (extension CLM-OTAF-ONE-IND-CD)."""
clm_rev_packg_ind_cd: str | None = None
"""Packaging indicator (extension CLM-REV-PACKG-IND-CD)."""
clm_rev_pmt_mthd_cd: str | None = None
"""Payment method indicator (extension CLM-REV-PMT-MTHD-CD)."""
clm_line_ansthsa_unit_cnt: float | None = None
"""Anesthesia units (extension CLM-LINE-ANSTHSA-UNIT-CNT)."""
clm_physn_astnt_cd: str | None = None
"""Reduced payment physician assistant code (extension CLM-PHYSN-ASTNT-CD)."""
clm_line_carr_hpsa_scrcty_cd: str | None = None
"""HPSA scarcity (extension CLM-LINE-CARR-HPSA-SCRCTY-CD)."""
clm_rndrg_prvdr_prtcptg_cd: str | None = None
"""Rendering provider participating (extension CLM-RNDRG-PRVDR-PRTCPTG-CD)."""
clm_rndrg_prvdr_type_cd: str | None = None
"""Rendering provider type (extension CLM-PRVDR-TYPE-CD)."""
clm_prvdr_spclty_cd: str | None = None
"""Line provider specialty (extension CLM-PRVDR-SPCLTY-CD)."""
clm_1_rev_cntr_ansi_rsn_cd: str | None = None
"""Adjustment reason 1 (adjudication reason ANSI-RSN-CD)."""
clm_1_rev_cntr_ansi_grp_cd: str | None = None
"""Adjustment group 1 (adjudication reason ANSI-GRP-CD)."""
clm_2_rev_cntr_ansi_rsn_cd: str | None = None
"""Adjustment reason 2."""
clm_2_rev_cntr_ansi_grp_cd: str | None = None
"""Adjustment group 2."""
clm_3_rev_cntr_ansi_rsn_cd: str | None = None
"""Adjustment reason 3."""
clm_3_rev_cntr_ansi_grp_cd: str | None = None
"""Adjustment group 3."""
clm_4_rev_cntr_ansi_rsn_cd: str | None = None
"""Adjustment reason 4."""
clm_4_rev_cntr_ansi_grp_cd: str | None = None
"""Adjustment group 4."""
# ── v3-only: item adjudication amounts (CodeSystem/Adjudication) ──
tot_rx_cst_amt: float | None = None
"""Total drug cost, Part D (TOT_RX_CST_AMT)."""
clm_line_grs_above_thrshld_amt: float | None = None
"""Gross drug cost above OOP threshold (CLM_LINE_GRS_ABOVE_THRSHLD_AMT)."""
clm_line_grs_blw_thrshld_amt: float | None = None
"""Gross drug cost below OOP threshold (CLM_LINE_GRS_BLW_THRSHLD_AMT)."""
clm_line_lis_amt: float | None = None
"""Low income cost sharing subsidy (CLM_LINE_LIS_AMT)."""
clm_line_plro_amt: float | None = None
"""Patient liability reduction other paid (CLM_LINE_PLRO_AMT)."""
clm_line_rptd_gap_dscnt_amt: float | None = None
"""Reported gap discount (CLM_LINE_RPTD_GAP_DSCNT_AMT)."""
clm_line_ncvrd_pd_amt: float | None = None
"""Noncovered paid (CLM_LINE_NCVRD_PD_AMT)."""
clm_line_bene_pd_amt: float | None = None
"""Paid to beneficiary (CLM_LINE_BENE_PD_AMT)."""
clm_line_mdcr_ddctbl_amt: float | None = None
"""Part B / cash deductible (CLM_LINE_MDCR_DDCTBL_AMT)."""
clm_line_prfnl_dme_price_amt: float | None = None
"""DME purchase price (CLM_LINE_PRFNL_DME_PRICE_AMT)."""
clm_line_dmerc_scrn_svgs_amt: float | None = None
"""DMERC screen savings (CLM_LINE_DMERC_SCRN_SVGS_AMT)."""
clm_line_add_on_pymt_amt: float | None = None
"""Add-on payment (CLM_LINE_ADD_ON_PYMT_AMT)."""
clm_rev_cntr_tdapa_amt: float | None = None
"""Transitional drug add-on payment adjustment (CLM_REV_CNTR_TDAPA_AMT)."""
clm_line_carr_clncl_chrg_amt: float | None = None
"""Carrier clinical charge (CLM_LINE_CARR_CLNCL_CHRG_AMT)."""
clm_line_carr_psych_ot_lmt_amt: float | None = None
"""Therapy amount applied to limit (CLM_LINE_CARR_PSYCH_OT_LMT_AMT)."""
clm_line_prfnl_intrst_amt: float | None = None
"""Professional interest (CLM_LINE_PRFNL_INTRST_AMT)."""
clm_mdcr_prmry_pyr_alowd_amt: float | None = None
"""Line primary payer allowed (CLM_MDCR_PRMRY_PYR_ALOWD_AMT)."""

View File

@@ -1,10 +1,18 @@
"""FHIR R4 Patient resource — beneficiary demographics and identifiers.
Flattened from the BCDA v2 Patient ndjson export. One row per
Flattened from the BCDA Patient ndjson export (v2 or v3 — the
flattener detects the dialect per resource). One row per
beneficiary. Maps CCLF8 (demographics) and CCLF9 (XREF) fields
to their FHIR R4 element paths.
Source: ``/api/v2/Patient/$export`` bulk FHIR endpoint.
v3 differences (BCDA v3 Data Dictionary, ``ccwMapping`` column):
``id`` is the IDR beneficiary surrogate key (BENE_SK), not the CCW
BENE_ID; race moves to the US Core extension (OMB codes, crosswalked
back into ``bene_race_cd``); the ``rfrnc_yr``/``dual_*`` extensions
are gone (dual status lives on its own Coverage row); the contact
language and historical source keys are new.
Source: ``/api/{v2,v3}/Patient/$export`` bulk FHIR endpoint.
"""
from __future__ import annotations
@@ -166,3 +174,21 @@ class Patient(SQLTable):
dual_12: str | None = None
"""Dual eligibility status — December."""
# ── v3-only (BCDA v3 Data Dictionary) ────────────────────
cntct_lang_cd: str | None = None
"""Contact language, BCP-47 (Patient.communication.language;
IDR CNTCT_LANG_CD)."""
xref_efctv_sk: str | None = None
"""Current Patient FHIR id after cross-reference — the
``replaced-by`` link target when present, else ``id``
(IDR BENE_XREF_EFCTV_SK)."""
bene_sk_list: str | None = None
"""Semicolon-joined historical source keys
(Patient.link.other.display ∪ Patient.id)."""
mbi_efctv_dt: date | None = None
"""Current MBI effective date (Patient.identifier[us-mbi].period.start;
IDR BENE_MBI_EFCTV_DT)."""

View File

@@ -55,8 +55,10 @@ def bcda(
) -> None:
"""Flatten BCDA FHIR exports and load into DuckDB.
Reads Patient/EOB/Coverage ndjson files, flattens them to Parquet,
and loads into the ``bcda`` schema.
Reads Patient/EOB/Coverage ndjson files (v2 or v3 payloads, any
number of parts), flattens them to Parquet, and loads into the
``bcda`` schema. Without --ndjson-dir, uses the newest export per
resource type under the BCDA store (a v3 pull is three jobs).
"""
from aco.load.bcda import load_bcda

View File

@@ -490,7 +490,7 @@ class TestLoadBcdaFindLatest:
(exports / "job1").mkdir()
result = _find_latest_export(tmp_path)
assert result.name == "job1"
assert [d.name for d in result] == ["job1"]
def test_find_latest_no_exports_dir(self, tmp_path):
from aco.load.bcda import _find_latest_export

View File

@@ -29,4 +29,96 @@ class TestFindLatestExport:
# Set old_job mtime to past so new_job is clearly newer
os.utime(exports / "old_job", (1000000, 1000000))
result = _find_latest_export(tmp_path)
assert result.name == "new_job"
assert [d.name for d in result] == ["new_job"]
def test_newest_per_resource_type(self, tmp_path: Path) -> None:
"""Three single-type v3 jobs: each type comes from its own dir."""
import os
exports = tmp_path / "exports"
exports.mkdir()
for i, (name, rtype) in enumerate(
[
("j_eob", "ExplanationOfBenefit"),
("j_pat", "Patient"),
("j_cov", "Coverage"),
]
):
d = exports / name
d.mkdir()
(d / f"{rtype}.ndjson").write_text("{}\n")
os.utime(d, (1000000 + i, 1000000 + i))
# a newer Patient-only job supersedes j_pat
d = exports / "j_pat2"
d.mkdir()
(d / "Patient.ndjson").write_text("{}\n")
os.utime(d, (2000000, 2000000))
result = _find_latest_export(tmp_path)
assert [d.name for d in result] == ["j_eob", "j_cov", "j_pat2"]
class TestLoadV3EndToEnd:
"""Official v3 samples, split like three single-type jobs → DuckDB."""
def test_load_from_three_export_dirs(self, tmp_path: Path) -> None:
import shutil
import duckdb
from aco.load.bcda import load_bcda
from conf import path as conf_path
samples = Path(__file__).resolve().parents[2] / "dev" / "vendor" / "bcda-site"
store = tmp_path / "store"
for job, rtype in (
("j1", "Patient"),
("j2", "Coverage"),
("j3", "ExplanationOfBenefit"),
):
d = store / "exports" / job
d.mkdir(parents=True)
shutil.copy(samples / f"v3_{rtype}.ndjson", d / f"{rtype}.ndjson")
# a second EOB part in the same job dir
shutil.copy(
samples / "v3_ExplanationOfBenefit.ndjson",
store / "exports" / "j3" / "ExplanationOfBenefit.1.ndjson",
)
db = tmp_path / "aco.duckdb"
from unittest.mock import patch
real_path = conf_path
def fake_path(key: str):
if key == "storage.bcda":
return store
return real_path(key)
with patch("conf.path", side_effect=fake_path):
stats = load_bcda(database=str(db))
assert stats == {
"bcda.patient": 94,
"bcda.explanation_of_benefit": 538,
"bcda.eob_item": 2464,
"bcda.coverage": 175,
}
con = duckdb.connect(str(db), read_only=True)
plans = dict(
con.execute(
"select class_plan, count(*) from bcda.coverage group by 1"
).fetchall()
)
assert set(plans) == {"Part A", "Part B", "Part C", "Dual Medicare/Medicaid"}
sys_types = dict(
con.execute(
"select system_type, count(*) from bcda.explanation_of_benefit group by 1"
).fetchall()
)
assert sys_types == {"NationalClaimsHistory": 282, "DDPS": 256}
assert (
con.execute(
"select count(*) from bcda.patient where bene_mbi_id is null"
).fetchone()[0]
== 0
)
con.close()

View File

@@ -49,7 +49,7 @@ class TestLoadBcda:
from aco.load.bcda import _find_latest_export
result = _find_latest_export(tmp_path)
assert result.is_dir()
assert len(result) == 1 and result[0].is_dir()
class TestStageAll:

View File

@@ -846,7 +846,7 @@ class TestClientDefaults:
def test_defaults(self) -> None:
c = Client("cid", "csec")
assert c.base_url == SANDBOX
assert c.version == "v2"
assert c.version == "v3"
assert c.max_retries == 3
from conf import path

View File

@@ -0,0 +1,388 @@
"""bcda.client — v3 additions: typeFilter, OperationOutcome errors,
job history Bundle, completed-job reuse, cancellation, version/health."""
from __future__ import annotations
import gzip
from unittest.mock import patch
import httpx
import pytest
from bcda.client import (
ASYNC_HEADERS,
SANDBOX,
SYSTEM_TYPES,
BcdaHttpError,
Client,
JobCancelledError,
_requested_types,
operation_outcome,
raise_for_status,
type_filter_tag,
)
def _token_response(tok: str = "tok") -> httpx.Response:
return httpx.Response(200, json={"access_token": tok, "expires_in": 1200})
def _oo(text: str, *, code: int = 400, details: bool = False) -> httpx.Response:
issue: dict = {"severity": "error", "code": "structure"}
if details:
issue["details"] = {"text": text}
else:
issue["diagnostics"] = text
return httpx.Response(
code, json={"resourceType": "OperationOutcome", "issue": [issue]}
)
def _client(handler, **kw) -> Client:
kw.setdefault("output_dir", "/tmp/bcda-test")
c = Client("cid", "csec", **kw)
c._http = httpx.Client(transport=httpx.MockTransport(handler))
return c
class TestHelpers:
def test_type_filter_tag(self) -> None:
assert type_filter_tag("DDPS") == (
"ExplanationOfBenefit?_tag=https://bluebutton.cms.gov/fhir/CodeSystem/System-Type|DDPS"
)
assert type_filter_tag(*SYSTEM_TYPES).count("|") == 3
with pytest.raises(ValueError):
type_filter_tag("Bogus")
with pytest.raises(ValueError):
type_filter_tag()
def test_operation_outcome(self) -> None:
assert (
operation_outcome(_oo("Prefer header is required"))
== "Prefer header is required"
)
assert operation_outcome(_oo("via details", details=True)) == "via details"
assert operation_outcome(httpx.Response(500, content=b"<html>")) == ""
assert (
operation_outcome(httpx.Response(404, json={"resourceType": "Bundle"}))
== ""
)
assert operation_outcome(httpx.Response(404, json=[1, 2])) == ""
assert (
operation_outcome(
httpx.Response(
400,
json={
"resourceType": "OperationOutcome",
"issue": [{"severity": "error"}],
},
)
)
== ""
)
def test_raise_for_status(self) -> None:
raise_for_status(httpx.Response(200)) # no-op
resp = _oo("bad", code=404)
resp.request = httpx.Request("GET", "https://x/y")
with pytest.raises(BcdaHttpError) as ei:
raise_for_status(resp)
assert ei.value.status_code == 404
assert ei.value.diagnostics == "bad"
assert "bad" in str(ei.value)
assert isinstance(ei.value, httpx.HTTPStatusError)
plain = httpx.Response(500, content=b"")
plain.request = httpx.Request("GET", "https://x/y")
with pytest.raises(BcdaHttpError) as ei:
raise_for_status(plain)
assert ei.value.diagnostics == "" and "500" in str(ei.value)
def test_requested_types(self) -> None:
req = "GET https://sandbox.bcda.cms.gov/api/v3/Group/all/$export?_type=Patient%2CCoverage%2CExplanationOfBenefit"
assert _requested_types(req) == ["Patient", "Coverage", "ExplanationOfBenefit"]
assert _requested_types("GET https://x/api/v3/Group/all/$export") == []
assert _requested_types("https://x/api/v3/Patient/$export?_type=Patient") == [
"Patient"
]
class TestExportTypeFilter:
def test_params_sent(self) -> None:
captured: dict = {}
def handler(req: httpx.Request) -> httpx.Response:
if "/auth/token" in str(req.url):
return _token_response()
captured["url"] = str(req.url)
captured["prefer"] = req.headers.get("prefer")
return httpx.Response(
202, headers={"Content-Location": f"{SANDBOX}/api/v3/jobs/1"}
)
c = _client(handler)
url = c.start_export(
types=["ExplanationOfBenefit"],
since="2026-01-01T00:00:00Z",
type_filter=type_filter_tag("SharedSystem"),
)
assert url.endswith("/api/v3/jobs/1")
assert "_typeFilter=ExplanationOfBenefit%3F_tag%3D" in captured["url"]
assert "_since=2026-01-01" in captured["url"]
assert captured["prefer"] == "respond-async"
c.close()
def test_export_passes_type_filter(self, tmp_path) -> None:
calls: list[str] = []
def handler(req: httpx.Request) -> httpx.Response:
url = str(req.url)
calls.append(url)
if "/auth/token" in url:
return _token_response()
if "$export" in url:
return httpx.Response(
202, headers={"Content-Location": f"{SANDBOX}/api/v3/jobs/9"}
)
if url.endswith("/jobs/9"):
return httpx.Response(
200,
json={
"output": [
{"type": "Patient", "url": f"{SANDBOX}/data/9/a.ndjson"}
],
"error": [],
},
)
return httpx.Response(200, content=b'{"resourceType":"Patient"}\n')
c = _client(handler, output_dir=tmp_path)
paths = c.export(
types=["Patient"],
type_filter="ExplanationOfBenefit?outcome=partial",
poll_interval=0,
)
assert [p.name for p in paths] == ["Patient.ndjson"]
assert any("_typeFilter=" in u for u in calls)
c.close()
class TestErrors:
def test_client_error_carries_diagnostics(self) -> None:
def handler(req: httpx.Request) -> httpx.Response:
if "/auth/token" in str(req.url):
return _token_response()
return _oo("application/fhir+json is the only supported response format")
c = _client(handler)
with pytest.raises(BcdaHttpError, match="only supported response format"):
c._request("GET", f"{SANDBOX}/api/v3/jobs")
c.close()
def test_auth_failure_diagnostics(self) -> None:
def handler(req: httpx.Request) -> httpx.Response:
return _oo("invalid client", code=401)
c = _client(handler)
with pytest.raises(BcdaHttpError, match="invalid client"):
c.authenticate()
c.close()
@patch("time.sleep")
def test_5xx_exhausted_is_bcda_error(self, _sleep) -> None:
def handler(req: httpx.Request) -> httpx.Response:
if "/auth/token" in str(req.url):
return _token_response()
return httpx.Response(503, content=b"down")
c = _client(handler, max_retries=2)
with pytest.raises(BcdaHttpError) as ei:
c._request("GET", f"{SANDBOX}/x")
assert ei.value.status_code == 503
c.close()
def test_poll_cancelled(self) -> None:
def handler(req: httpx.Request) -> httpx.Response:
if "/auth/token" in str(req.url):
return _token_response()
return _oo("Job has been cancelled.", code=404)
c = _client(handler)
with pytest.raises(JobCancelledError, match="cancelled"):
c.poll(f"{SANDBOX}/api/v3/jobs/5", interval=0)
c.close()
def test_poll_other_404_propagates(self) -> None:
def handler(req: httpx.Request) -> httpx.Response:
if "/auth/token" in str(req.url):
return _token_response()
return _oo("Job not found", code=404)
c = _client(handler)
with pytest.raises(BcdaHttpError, match="not found"):
c.poll(f"{SANDBOX}/api/v3/jobs/5", interval=0)
c.close()
def test_poll_sends_bulk_headers(self) -> None:
seen: dict = {}
def handler(req: httpx.Request) -> httpx.Response:
if "/auth/token" in str(req.url):
return _token_response()
seen.update({k.lower(): v for k, v in req.headers.items()})
return httpx.Response(200, json={"output": []})
c = _client(handler)
c.poll(f"{SANDBOX}/api/v3/jobs/5", interval=0)
assert (
seen["prefer"] == "respond-async"
and seen["accept"] == ASYNC_HEADERS["Accept"]
)
c.close()
def test_download_error_has_diagnostics(self, tmp_path) -> None:
def handler(req: httpx.Request) -> httpx.Response:
if "/auth/token" in str(req.url):
return _token_response()
return _oo("file expired", code=410)
c = _client(handler, output_dir=tmp_path)
with pytest.raises(BcdaHttpError, match="file expired"):
c._download_file(f"{SANDBOX}/data/1/x.ndjson", tmp_path / "x.ndjson")
c.close()
def test_download_gzip_fixture(self, tmp_path) -> None:
"""The upstream gzip fixture decodes to the same bytes as the plain one."""
from pathlib import Path
fixtures = (
Path(__file__).resolve().parents[2]
/ "dev"
/ "vendor"
/ "bcda-app"
/ "shared_files"
/ "gzip_feature_test"
)
gz = (fixtures / "test_gzip_encoded.ndjson").read_bytes()
plain = (fixtures / "test_no_encoding.ndjson").read_bytes()
assert gzip.decompress(gz).strip() == plain.strip()
def handler(req: httpx.Request) -> httpx.Response:
if "/auth/token" in str(req.url):
return _token_response()
return httpx.Response(200, content=gz, headers={"Content-Encoding": "gzip"})
c = _client(handler, output_dir=tmp_path)
c._download_file(f"{SANDBOX}/data/1/x.ndjson", tmp_path / "x.ndjson")
assert (tmp_path / "x.ndjson").read_bytes().strip() == plain.strip()
c.close()
def _task(job_id: str, status: str, request: str, end: str) -> dict:
return {
"resourceType": "Task",
"identifier": [{"system": f"{SANDBOX}/api/v3/jobs", "value": job_id}],
"status": status,
"input": [{"type": {"text": "BULK FHIR Export"}, "valueString": request}],
"executionPeriod": {"start": "2026-10-25T00:00:00Z", "end": end},
}
class TestJobs:
def test_bundle_unwrapped_with_headers(self) -> None:
seen: dict = {}
def handler(req: httpx.Request) -> httpx.Response:
if "/auth/token" in str(req.url):
return _token_response()
seen["prefer"] = req.headers.get("prefer")
seen["accept"] = req.headers.get("accept")
return httpx.Response(
200,
json={
"resourceType": "Bundle",
"type": "searchset",
"entry": [
{"resource": _task("1", "completed", "GET x", "2026")},
{"fullUrl": "no-resource"},
],
},
)
c = _client(handler)
jobs = c.jobs()
assert len(jobs) == 1 and jobs[0]["status"] == "completed"
assert seen == {"prefer": "respond-async", "accept": "application/fhir+json"}
c.close()
def test_status_404_means_empty(self) -> None:
def handler(req: httpx.Request) -> httpx.Response:
if "/auth/token" in str(req.url):
return _token_response()
return _oo("no jobs", code=404)
c = _client(handler)
assert c.jobs(status="Completed") == []
with pytest.raises(BcdaHttpError):
c.jobs() # unfiltered 404 is a real error
c.close()
def test_completed_jobs(self) -> None:
tasks = [
_task(
"10",
"completed",
f"GET {SANDBOX}/api/v3/Group/all/$export?_type=Patient",
"2026-10-25T10:00:00Z",
),
_task(
"11",
"completed",
f"GET {SANDBOX}/api/v3/Group/all/$export?_type=Coverage%2CPatient",
"2026-10-25T12:00:00Z",
),
_task(
"12",
"cancelled",
f"GET {SANDBOX}/api/v3/Group/all/$export?_type=Patient",
"2026-10-25T13:00:00Z",
),
_task(
"13",
"completed",
f"GET {SANDBOX}/api/v2/Group/all/$export?_type=Patient",
"2026-10-25T14:00:00Z",
),
{"resourceType": "Task", "status": "completed"},
]
def handler(req: httpx.Request) -> httpx.Response:
if "/auth/token" in str(req.url):
return _token_response()
return httpx.Response(
200,
json={
"resourceType": "Bundle",
"entry": [{"resource": t} for t in tasks],
},
)
c = _client(handler)
got = c.completed_jobs()
assert [j["job_id"] for j in got] == ["11", "10"] # newest first, v3 only
assert got[0]["types"] == ["Coverage", "Patient"]
assert got[0]["job_url"].endswith("/api/v3/jobs/11")
c.close()
class TestInfoEndpoints:
def test_version_and_health_unauthenticated(self) -> None:
def handler(req: httpx.Request) -> httpx.Response:
assert "Authorization" not in req.headers
if str(req.url).endswith("/_version"):
return httpx.Response(200, json={"version": "r302"})
return httpx.Response(200, json={"database": "ok"})
c = _client(handler)
assert c.server_version()["version"] == "r302"
assert c.health()["database"] == "ok"
c.close()

View File

@@ -2727,3 +2727,81 @@ class TestFlattenExport:
assert "bcda.patient" in counts
assert "bcda.explanation_of_benefit" not in counts
assert "bcda.coverage" not in counts
# ── v3 coverage shape through the CCLF demographics stage ───────
class TestDemographicsV3Coverage:
"""v3 Coverage: one row per plan; dual status on its own row."""
def _frames(self):
import polars as pl
patient = pl.DataFrame(
{
"bene_mbi_id": ["M1", "M2", "M3"],
"bene_sex_cd": ["1", "2", "1"],
"bene_race_cd": ["1", "2", None],
"bene_dob": [None, None, None],
"bene_death_dt": [None, None, None],
"bene_1st_name": ["a", "b", "c"],
"bene_midl_name": [None, None, None],
"bene_last_name": ["x", "y", "z"],
"bene_line_1_adr": [None, None, None],
"geo_zip_plc_name": [None, None, None],
"geo_usps_state_cd": [None, None, None],
"geo_zip5_cd": [None, None, None],
}
)
coverage = pl.DataFrame(
{
"subscriber_id": ["M1", "M1", "M1", "M2", "M2", "M3"],
"class_plan": [
"Part C",
"Part B",
"Part A",
"Part B",
"Dual Medicare/Medicaid",
"Part A",
],
"ms_cd": [None, "10", "20", "10", None, "30"],
"orec": [None, "0", "1", "0", None, "2"],
"dual_01": [None, None, None, None, None, "04"],
"dual_stus_cd": [None, None, None, None, "08", None],
"bene_part_a_enrlmt_bgn_dt": [None, None, None, None, None, None],
"mdcr_covg_a_end_dt": [None, None, None, None, None, None],
}
)
return patient, coverage
def test_part_a_preferred_and_dual_joined(self) -> None:
from bcda.express.cclf import eligibility, stg_beneficiary_demographics
patient, coverage = self._frames()
demo = stg_beneficiary_demographics(patient, coverage).sort("bene_mbi_id")
assert demo["ms_cd"].to_list() == [
"20",
"10",
"30",
] # M1 → Part A row, not Part C/B
assert demo["dual_stus_cd_dual"].to_list() == [None, "08", None]
elig = eligibility(demo).sort("person_id")
assert elig["dual_status_code"].to_list() == [None, "08", "04"]
assert elig["medicare_status_code"].to_list() == ["20", "10", "30"]
assert elig["original_reason_entitlement_code"].to_list() == ["1", "0", "2"]
def test_v2_frames_without_plan_columns_still_work(self) -> None:
import polars as pl
from bcda.express.cclf import eligibility, stg_beneficiary_demographics
patient, coverage = self._frames()
v2cov = coverage.drop("class_plan", "dual_stus_cd").unique(
subset=["subscriber_id"], keep="first"
)
demo = stg_beneficiary_demographics(patient, v2cov)
assert "dual_stus_cd_dual" not in demo.columns
elig = eligibility(demo).sort("person_id")
assert elig["dual_status_code"].to_list() == [None, None, "04"]
assert isinstance(elig, pl.DataFrame)

View File

@@ -0,0 +1,148 @@
"""bcda.express.flatten — multi-part resolution and streaming Parquet output."""
from __future__ import annotations
import json
import os
from datetime import date
from pathlib import Path
import fsspec
import pyarrow as pa
import pyarrow.parquet as pq
from bcda.express import flatten
from bcda.table.coverage import Coverage
from bcda.table.eob import EobItem, ExplanationOfBenefit
from bcda.table.patient import Patient
ROOT = Path(__file__).resolve().parents[2]
SAMPLES = ROOT / "dev" / "vendor" / "bcda-site"
class TestResolveNdjson:
def test_parts_sorted_and_later_dirs_win(self, tmp_path: Path) -> None:
a, b = tmp_path / "a", tmp_path / "b"
a.mkdir()
b.mkdir()
for name in (
"ExplanationOfBenefit.10.ndjson",
"ExplanationOfBenefit.ndjson",
"ExplanationOfBenefit.2.ndjson",
"ExplanationOfBenefitEmpty.ndjson",
"Patient.ndjson",
):
(a / name).write_text("{}\n")
(b / "Patient.ndjson").write_text("{}\n")
(b / "Coverage.ndjson").write_text("{}\n")
found = flatten.resolve_ndjson([a, b])
assert [p.name for p in found["ExplanationOfBenefit"]] == [
"ExplanationOfBenefit.ndjson",
"ExplanationOfBenefit.2.ndjson",
"ExplanationOfBenefit.10.ndjson",
]
assert found["Patient"] == [b / "Patient.ndjson"]
assert found["Coverage"] == [b / "Coverage.ndjson"]
assert flatten.resolve_ndjson(str(a))["Patient"] == [a / "Patient.ndjson"]
assert flatten.resolve_ndjson(tmp_path / "missing") == {}
class TestArrowSchema:
def test_types(self) -> None:
s = flatten.arrow_schema(EobItem)
assert s.field("clm_id").type == pa.string()
assert s.field("clm_line_num").type == pa.float64()
assert s.field("clm_line_from_dt").type == pa.date32()
assert set(s.names) == set(EobItem.column_names())
for model in (Patient, Coverage, ExplanationOfBenefit):
assert flatten.arrow_schema(model).names == model.column_names()
def test_unknown_annotation_falls_back_to_string(self) -> None:
from conf.table_base import SQLTable
class Odd(SQLTable):
weird: dict | None = None
plain: int = 0
s = flatten.arrow_schema(Odd)
assert s.field("weird").type == pa.string()
assert s.field("plain").type == pa.int64()
class TestParquetSink:
def test_writes_batches_and_skips_empty(self, tmp_path: Path) -> None:
fs = fsspec.filesystem("file")
path = str(tmp_path / "flat" / "p.parquet")
sink = flatten._ParquetSink(fs, path, flatten.arrow_schema(Patient))
sink.write([])
sink.write([{"id": "1", "bene_dob": date(2000, 1, 1)}])
sink.write([{"id": "2"}])
sink.close()
assert sink.rows == 2
t = pq.read_table(path)
assert t.num_rows == 2 and t.schema.field("bene_dob").type == pa.date32()
assert t.column("id").to_pylist() == ["1", "2"]
class TestFlattenExportStreaming:
def test_official_samples_in_small_batches(
self, tmp_path: Path, monkeypatch
) -> None:
"""Batch size 7 forces many Arrow batches; counts must not change."""
import shutil
monkeypatch.setattr(flatten, "BATCH_ROWS", 7)
src = tmp_path / "ndjson"
src.mkdir()
shutil.copy(SAMPLES / "v3_Patient.ndjson", src / "Patient.ndjson")
shutil.copy(SAMPLES / "v3_Coverage.ndjson", src / "Coverage.ndjson")
# split the EOB sample into two parts to exercise multi-part reading
lines = (SAMPLES / "v3_ExplanationOfBenefit.ndjson").read_text().splitlines()
(src / "ExplanationOfBenefit.ndjson").write_text(
"\n".join(lines[:100]) + "\n\n"
)
(src / "ExplanationOfBenefit.1.ndjson").write_text(
"\n".join(lines[100:]) + "\n"
)
counts = flatten.flatten_export(src, tmp_path / "store")
assert counts == {
"bcda.patient": 94,
"bcda.explanation_of_benefit": 269,
"bcda.eob_item": 1232,
"bcda.coverage": 175,
}
eob = pq.read_table(
tmp_path / "store" / "flat" / "explanation_of_benefit.parquet"
)
assert eob.num_rows == 269
assert set(eob.column("system_type").to_pylist()) == {
"NationalClaimsHistory",
"DDPS",
}
assert eob.schema.field("clm_from_dt").type == pa.date32()
def test_iter_ndjson_skips_blank_lines(self, tmp_path: Path) -> None:
p = tmp_path / "x.ndjson"
p.write_text('{"a":1}\n\n \n{"a":2}\n')
assert [r["a"] for r in flatten._iter_ndjson([p])] == [1, 2]
assert flatten._read_ndjson(p) == [{"a": 1}, {"a": 2}]
def test_missing_types_write_nothing(self, tmp_path: Path) -> None:
src = tmp_path / "ndjson"
src.mkdir()
(src / "Patient.ndjson").write_text(
json.dumps(
{
"id": "P",
"meta": {
"profile": [
"http://hl7.org/fhir/us/carin-bb/StructureDefinition/C4BB-Patient|2.2.0"
]
},
}
)
+ "\n"
)
counts = flatten.flatten_export(src, tmp_path / "store")
assert counts == {"bcda.patient": 1}
assert sorted(os.listdir(tmp_path / "store" / "flat")) == ["patient.parquet"]

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
import pyarrow as pa
@@ -656,3 +657,177 @@ class TestStorageOptions:
def test_root_trailing_slash(self) -> None:
store = Store("data/bcda/")
assert store._root == "data/bcda"
# ── v3: adopt completed jobs, type_filter passthrough ───────────
class TestAdoptAndTypeFilter:
def _client(self, handler):
import httpx
from bcda.client import Client
c = Client("cid", "csec", output_dir="/tmp/bcda-store-test")
c._http = httpx.Client(transport=httpx.MockTransport(handler))
return c
def test_adopt_downloads_completed_job(self, tmp_path) -> None:
import httpx
from bcda.store import Store
def handler(req: httpx.Request) -> httpx.Response:
url = str(req.url)
if "/auth/token" in url:
return httpx.Response(
200, json={"access_token": "t", "expires_in": 1200}
)
if url.endswith("/jobs/77"):
return httpx.Response(
200,
json={
"output": [
{"type": "Patient", "url": "https://s/data/77/p.ndjson"},
{"type": "Patient", "url": "https://s/data/77/p2.ndjson"},
]
},
)
return httpx.Response(200, content=b'{"resourceType":"Patient"}\n')
store = Store(path=str(tmp_path))
client = self._client(handler)
key = store.adopt(
client, "https://s/api/v3/jobs/77", types=["Patient"], poll_interval=0
)
job = store.get_job(key)
assert job["status"] == "complete"
assert job["job_url"].endswith("/jobs/77")
assert job["types"] == "Patient"
files = store.get_files(key)
assert sorted(Path(f["storage_path"]).name for f in files) == [
"Patient.1.ndjson",
"Patient.ndjson",
]
assert all(f["status"] == "complete" for f in files)
client.close()
def test_adopt_failure_recorded(self, tmp_path) -> None:
import httpx
import pytest
from bcda.client import BcdaHttpError
from bcda.store import Store
def handler(req: httpx.Request) -> httpx.Response:
if "/auth/token" in str(req.url):
return httpx.Response(
200, json={"access_token": "t", "expires_in": 1200}
)
return httpx.Response(
404,
json={
"resourceType": "OperationOutcome",
"issue": [{"diagnostics": "Job has expired"}],
},
)
store = Store(path=str(tmp_path))
client = self._client(handler)
with pytest.raises(BcdaHttpError, match="expired"):
store.adopt(client, "https://s/api/v3/jobs/78", poll_interval=0)
failed = store.list_jobs(status="failed")
assert len(failed) == 1 and "expired" in failed[0]["error"]
client.close()
def test_export_records_type_filter(self, tmp_path) -> None:
import httpx
from bcda.store import Store
seen: dict = {}
def handler(req: httpx.Request) -> httpx.Response:
url = str(req.url)
if "/auth/token" in url:
return httpx.Response(
200, json={"access_token": "t", "expires_in": 1200}
)
if "$export" in url:
seen["url"] = url
return httpx.Response(
202, headers={"Content-Location": "https://s/api/v3/jobs/1"}
)
if url.endswith("/jobs/1"):
return httpx.Response(200, json={"output": []})
return httpx.Response(200, content=b"")
store = Store(path=str(tmp_path))
client = self._client(handler)
key = store.export(
client,
types=["ExplanationOfBenefit"],
type_filter="ExplanationOfBenefit?outcome=partial",
poll_interval=0,
)
assert "_typeFilter=" in seen["url"]
assert (
store.get_job(key)["types"]
== "ExplanationOfBenefit|_typeFilter=ExplanationOfBenefit?outcome=partial"
)
client.close()
def test_export_retries_after_server_cancellation(self, tmp_path) -> None:
import httpx
import pytest
from bcda.client import JobCancelledError
from bcda.store import Store
polls = {"n": 0}
def handler(req: httpx.Request) -> httpx.Response:
url = str(req.url)
if "/auth/token" in url:
return httpx.Response(
200, json={"access_token": "t", "expires_in": 1200}
)
if "$export" in url:
polls["n"] += 1
return httpx.Response(
202,
headers={"Content-Location": f"https://s/api/v3/jobs/{polls['n']}"},
)
if url.endswith("/jobs/1") or url.endswith("/jobs/2"):
return httpx.Response(
404,
json={
"resourceType": "OperationOutcome",
"issue": [{"diagnostics": "Job has been cancelled."}],
},
)
if url.endswith("/jobs/3"):
return httpx.Response(200, json={"output": []})
return httpx.Response(200, content=b"")
store = Store(path=str(tmp_path))
client = self._client(handler)
key = store.export(client, types=["Patient"], poll_interval=0, cancel_retries=2)
job = store.get_job(key)
assert job["status"] == "complete" and job["job_url"].endswith("/jobs/3")
history = store._read_parquet(
store._jobs_path, store._read_parquet.__globals__["JOBS_SCHEMA"]
)
statuses = [r["status"] for r in history.to_pylist() if r["key"] == key]
assert statuses.count("cancelled") == 2 and statuses[-1] == "complete"
client.close()
# no retries left → the cancellation propagates and is recorded
polls["n"] = 0
client = self._client(handler)
with pytest.raises(JobCancelledError):
store.export(client, types=["Patient"], poll_interval=0)
assert store.list_jobs(status="failed")[-1]["error"].startswith(
"https://s/api/v3/jobs/1"
)
client.close()

1321
tests/bcda/test_v3.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1060,8 +1060,9 @@ class TestAcoLoadBcdaFindLatest:
d1.mkdir()
d2 = exports_dir / "export-002"
d2.mkdir()
(d2 / "Patient.ndjson").write_text("{}\n")
result = _find_latest_export(tmp_path)
assert result.is_dir() # line 51
assert result == [d2] # only the dir that holds a resource file
class TestAcoLoadSeedParquet: