Files
stack/tests/bcda/test_flatten_stream.py
kert d08401dc53 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
2026-10-25 13:08:13 -04:00

149 lines
5.4 KiB
Python

"""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"]