Files
stack/dev/scripts/ingest_asp.py
kert 579d17beb4
All checks were successful
CI / lint (push) Successful in 42s
CI / notebooks-smoke (push) Successful in 1m27s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 50s
Infra CI / zotero (push) Successful in 13s
Infra CI / docs (push) Successful in 1m12s
Infra CI / api (push) Successful in 1m0s
Infra CI / mc (push) Successful in 17s
Deploy / report (push) Successful in 12s
CI / test (push) Successful in 17m21s
feat(conf): notebook read-replica aco.ro.duckdb (closes #510)
conf.connect.publish_replica(): holds the primary's write lock,
CHECKPOINTs the WAL, copies to <name>.ro.duckdb, swaps atomically —
so the snapshot is always a consistent database and readers holding
the old file keep a valid handle. Both ingest scripts republish as
their final step, bounding staleness to ingest cadence.

conf.connect.duckdb(): read-only opens resolve to the replica when it
exists — notebook kernels never hold the primary's single-writer lock,
so ingests stop failing under open notebooks (the M2/option-C fix from
the concurrency spec). Opt-outs: replica=False param or
STACK_DUCKDB_REPLICA=0. Write opens always use the primary.

Validated live: replica published (3.1 GB), and the notebooks
container resolves conf.connect.duckdb() to /home/kert/data/
aco.ro.duckdb with the rulemaking-comments data visible. 82 conf
tests green (snapshot consistency, divergence, opt-outs, atomic
republish).
2026-07-10 22:23:57 -04:00

363 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Ingest CMS Average Sales Price (ASP) Drug Pricing Files.
Reads all quarterly ASP ZIP files from data/cms/asp/, normalises column
names across 20+ years of CMS format drift, filters **strictly** to
skin substitute HCPCS codes defined in data/cms/skin_substitute_hcpcs.csv,
and produces:
* data/cms/asp/skin_subs_asp_quarterly.csv (flat file with provenance)
* skin_subs.asp_quarterly (DuckDB table)
Every output row carries ``source_url`` and ``source_file`` columns for
full provenance — each observation traces back to a specific CMS
quarterly ZIP and the file within it that was parsed.
Source URLs are read from data/cms/asp_source_urls.csv (canonical URLs
provided from CMS.gov, one per quarter 2005-Q1 through 2026-Q2).
Column mapping rationale
------------------------
CMS has changed column names across 20+ years of ASP files:
20052006: "HCPCS Code", "Short Description", "HCPCS Code Dosage",
"Estimated ASP", "Payment Limit"
20072011: Same but sometimes "Dosage" instead of "HCPCS Code Dosage"
20122019: "HCPCS Code", "Short Description", "Dosage",
"Payment Limit"
20202025: "HCPCS Code", "Short Description", "HCPCS Code Dosage",
"Payment Limit"
2026+: "HCPCS Code", "Short Description", "Dosage or Unit",
"Payment Limit" (now "Payment Limit" for skin subs is
flat $127.14/cm² under reclassification)
All variants are mapped to: hcpcs_code, short_description,
dosage_or_unit, payment_limit.
The ``asp_per_unit`` column is derived: payment_limit / 1.06, since
CMS payment limit = ASP + 6% for drugs/biologicals. For 2026+ skin
subs under reclassification, this derivation no longer applies (flat
rate), but we retain it for consistency and flag it in notes.
Filtering
---------
We filter STRICTLY to codes present in skin_substitute_hcpcs.csv
(the code universe built from CMS rulemaking in issue #232). This
is critical because the Q4xxx prefix includes non-skin-sub codes
(Q4054 darbepoetin alfa, Q4055 epoetin alfa, Q4074-Q4082 various
ESRD drugs) that must be excluded.
Usage:
uv run python dev/scripts/ingest_asp.py
"""
from __future__ import annotations
import csv
import io
import re
import zipfile
from pathlib import Path
import pandas as pd
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
ROOT = Path(__file__).resolve().parents[2]
ASP_DIR = ROOT / "data" / "cms" / "asp"
HCPCS_REF = ROOT / "data" / "cms" / "skin_substitute_hcpcs.csv"
URL_REF = ROOT / "data" / "cms" / "asp_source_urls.csv"
DUCKDB_PATH = ROOT / "data" / "aco.duckdb"
OUTPUT_CSV = ASP_DIR / "skin_subs_asp_quarterly.csv"
def _load_source_urls() -> dict[str, str]:
"""Load canonical source URLs: {quarter: url}."""
urls = {}
if URL_REF.exists():
with URL_REF.open() as f:
for row in csv.DictReader(f):
urls[row["quarter"]] = row["url"]
return urls
# ---------------------------------------------------------------------------
# Column normalisation
# ---------------------------------------------------------------------------
# Mapping from observed CMS column names (lowercased) to our standard names.
# Each entry documents which CMS file years use that variant.
COL_MAP = {
# HCPCS code — consistent across all years
"hcpcs code": "hcpcs_code",
"hcpcs_code": "hcpcs_code",
# Short description — consistent
"short description": "short_description",
"short_description": "short_description",
# Dosage/unit — varies by year
"hcpcs code dosage": "dosage_or_unit", # 2005-2006, 2020-2025
"hcpcs_code_dosage": "dosage_or_unit",
"dosage": "dosage_or_unit", # 2007-2019
"dosage or unit": "dosage_or_unit", # 2026+
# Payment limit — consistent (ASP + 6%)
"payment limit": "payment_limit",
"payment_limit": "payment_limit",
}
KEEP_COLS = ["hcpcs_code", "short_description", "dosage_or_unit", "payment_limit"]
def normalise_columns(df: pd.DataFrame) -> pd.DataFrame:
"""Lower-case columns, apply COL_MAP, keep only KEEP_COLS."""
df.columns = [str(c).strip().lower() for c in df.columns]
df = df.rename(columns=COL_MAP)
present = [c for c in KEEP_COLS if c in df.columns]
return df[present]
# ---------------------------------------------------------------------------
# Readers
# ---------------------------------------------------------------------------
def _find_header_row(lines: list[str]) -> int:
"""Return the 0-based row index of the column-header row.
The header row starts with 'HCPCS Code,' (possibly quoted).
Must NOT match note lines that incidentally mention 'HCPCS code'.
"""
for i, line in enumerate(lines):
stripped = line.strip().lower()
if stripped.startswith("hcpcs code,") or stripped.startswith('"hcpcs code",'):
return i
return -1
def read_csv_from_zip(zf: zipfile.ZipFile, name: str) -> pd.DataFrame:
"""Read a section-508 CSV inside a ZIP, skipping preamble rows."""
raw = zf.read(name).decode("latin-1")
lines = raw.splitlines()
hdr = _find_header_row(lines)
if hdr < 0:
return pd.DataFrame()
buf = "\n".join(lines[hdr:])
df = pd.read_csv(io.StringIO(buf), dtype=str, on_bad_lines="skip")
return normalise_columns(df)
def read_xls_from_zip(zf: zipfile.ZipFile, name: str) -> pd.DataFrame:
"""Read .xls inside a ZIP, skipping preamble rows."""
import xlrd
data = zf.read(name)
wb = xlrd.open_workbook(file_contents=data)
sh = wb.sheet_by_index(0)
hdr_row = -1
for r in range(min(30, sh.nrows)):
first_cell = str(sh.cell_value(r, 0)).strip().lower()
if first_cell == "hcpcs code":
hdr_row = r
break
if hdr_row < 0:
return pd.DataFrame()
headers = [str(sh.cell_value(hdr_row, c)).strip() for c in range(sh.ncols)]
rows = []
for r in range(hdr_row + 1, sh.nrows):
row = [str(sh.cell_value(r, c)).strip() for c in range(sh.ncols)]
if any(row):
rows.append(row)
df = pd.DataFrame(rows, columns=headers)
return normalise_columns(df)
def pick_pricing_file(names: list[str]) -> str | None:
"""From a list of files in a ZIP, pick the ASP pricing file.
Prefer CSV (section 508) over XLS. Skip NOC, crosswalk, NDC,
and "not payable" files.
"""
skip_patterns = ["noc", "crosswalk", "ndc", "not payable", "not_payable"]
candidates: list[str] = []
for n in names:
low = n.lower()
if any(s in low for s in skip_patterns):
continue
if low.endswith((".csv", ".xls", ".xlsx")):
candidates.append(n)
csvs = [c for c in candidates if c.lower().endswith(".csv")]
if csvs:
for c in csvs:
low = c.lower()
if "payment limit" in low or "pricing" in low:
return c
return csvs[0]
xlss = [c for c in candidates if c.lower().endswith((".xls", ".xlsx"))]
if xlss:
for c in xlss:
low = c.lower()
if "payment limit" in low or "pricing" in low or "asp" in low:
return c
return xlss[0]
return None
# ---------------------------------------------------------------------------
# Numeric cleaning
# ---------------------------------------------------------------------------
def clean_numeric(series: pd.Series) -> pd.Series:
"""Strip $ signs and convert to float, coercing errors to NaN."""
return (
series.astype(str)
.str.replace("$", "", regex=False)
.str.replace(",", "", regex=False)
.str.strip()
.pipe(pd.to_numeric, errors="coerce")
)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
# Load skin-substitute HCPCS codes (strict filter — no regex fallback)
hcpcs_ref = pd.read_csv(HCPCS_REF, dtype=str)
skin_codes = set(hcpcs_ref["hcpcs_code"].str.strip().str.upper())
print(f"Loaded {len(skin_codes)} skin-substitute HCPCS codes from reference")
# Load canonical source URLs
source_urls = _load_source_urls()
print(f"Loaded {len(source_urls)} source URLs")
# Ingest all quarters
zips = sorted(ASP_DIR.glob("asp_*.zip"))
print(f"Found {len(zips)} quarterly ZIP files\n")
frames: list[pd.DataFrame] = []
for zpath in zips:
m = re.match(r"asp_(\d{4}-Q\d)", zpath.name)
if not m:
print(f" SKIP {zpath.name} (cannot parse quarter)")
continue
quarter = m.group(1)
try:
with zipfile.ZipFile(zpath) as zf:
target = pick_pricing_file(zf.namelist())
if target is None:
print(f" SKIP {quarter}: no pricing file found in ZIP")
continue
if target.lower().endswith(".csv"):
df = read_csv_from_zip(zf, target)
else:
df = read_xls_from_zip(zf, target)
if df.empty:
print(f" SKIP {quarter}: empty after parsing {target}")
continue
df["quarter"] = quarter
df["source_file"] = target
df["source_url"] = source_urls.get(quarter, "")
frames.append(df)
print(f" OK {quarter}: {len(df):>5} rows from {target}")
except Exception as e:
print(f" ERR {quarter}: {e}")
if not frames:
raise RuntimeError("No data parsed from any file")
all_df = pd.concat(frames, ignore_index=True)
print(f"\nTotal rows across all quarters: {len(all_df)}")
# Normalise HCPCS codes
all_df["hcpcs_code"] = all_df["hcpcs_code"].astype(str).str.strip().str.upper()
# Filter STRICTLY to skin substitute code universe
# NO regex fallback — only codes in skin_substitute_hcpcs.csv
skin_df = all_df[all_df["hcpcs_code"].isin(skin_codes)].copy()
print(f"Skin substitute rows (strict filter): {len(skin_df)}")
# Show what we excluded
q4_but_not_skin = all_df[
all_df["hcpcs_code"].str.match(r"^Q4\d{2,3}$")
& ~all_df["hcpcs_code"].isin(skin_codes)
]["hcpcs_code"].unique()
if len(q4_but_not_skin) > 0:
print(
f" Excluded Q4 codes NOT in skin sub universe: {sorted(q4_but_not_skin)}"
)
if not skin_df.empty:
skin_df["payment_limit"] = clean_numeric(skin_df["payment_limit"])
# ASP per unit = payment_limit / 1.06
# (CMS payment limit = ASP + 6% for drugs/biologicals)
skin_df["asp_per_unit"] = (skin_df["payment_limit"] / 1.06).round(3)
# Reorder columns
out_cols = [
"quarter",
"hcpcs_code",
"short_description",
"asp_per_unit",
"payment_limit",
"dosage_or_unit",
"source_url",
"source_file",
]
for c in out_cols:
if c not in skin_df.columns:
skin_df[c] = None
skin_df = skin_df[out_cols].sort_values(["quarter", "hcpcs_code"])
# Write CSV
skin_df.to_csv(OUTPUT_CSV, index=False)
print(f"\nWrote {len(skin_df)} rows to {OUTPUT_CSV}")
if len(skin_df) > 0:
quarters = sorted(skin_df["quarter"].unique())
codes = sorted(skin_df["hcpcs_code"].unique())
print(f" Quarters: {len(quarters)} ({quarters[0]} to {quarters[-1]})")
print(f" Unique HCPCS codes: {len(codes)}")
# Load into DuckDB
print(f"\nLoading into DuckDB at {DUCKDB_PATH} ...")
# Lock preflight + retry instead of a raw IOException when a notebook
# kernel holds the single-writer file (#508).
from conf.connect import duckdb_batch, publish_replica
with duckdb_batch("aco") as con:
con.execute("CREATE SCHEMA IF NOT EXISTS skin_subs")
con.execute("DROP TABLE IF EXISTS skin_subs.asp_quarterly")
if not skin_df.empty:
con.execute(
"""
CREATE TABLE skin_subs.asp_quarterly AS
SELECT * FROM read_csv_auto(?, header=true)
""",
[str(OUTPUT_CSV)],
)
count = con.execute(
"SELECT count(*) FROM skin_subs.asp_quarterly"
).fetchone()[0]
print(f" Loaded {count} rows into skin_subs.asp_quarterly")
else:
print(" WARNING: No skin substitute data found in ASP files")
print(
" This may be expected — skin subs may not appear in main ASP pricing files"
)
print(" They may be in separate NOC or tissue coding files")
# Refresh the notebook read replica (#510).
print(f"replica → {publish_replica('aco')}")
if __name__ == "__main__":
main()