Files
stack/dev/scripts/download_opps_files.py
kert e9801f599e feat(opps): recover CY2014-2020 Addendum B + fix ingest finder
CMS purged the pre-2021 OPPS addenda in its 2023 site restructure, so
opps.addendum_b only held CY2021-2026. Recover CY2014-2020 from the
Internet Archive Wayback Machine (the `web/{ts}id_/{url}` raw-binary form)
and wire the URLs into download_opps_files.py, with retry/backoff since
Wayback rate-limits.

Key subtlety: the payment data (Relative Weight + Payment Rate per HCPCS)
lives in the real "Addendum B - Final OPPS Payment by HCPCS Code", which
ships inside the COMBINED addenda ZIP — NOT the "Data Addendum B /
2-Times-Rule" file (SI/APC/comment-indicator only, no payment). Point the
older years at the combined ZIPs (CY2016's only surviving snapshot is
2015-vintage but serves the real Addendum B).

Fix the ingest file-finder accordingly:
- match "Addendum B" separator-insensitively (CMS mixes "Addendum B.xlsx"
  and "CMS-1613-FC-Addendum-B.xlsx" across years);
- exclude the "Data Addendum B" file (normalised "dataadd") so it never
  shadows the payment file.

After this, all 13 years ingest and reconcile 100% exact against Addendum B.
Data files remain gitignored; download_opps_files.py + ingest_opps.py
reproduce them.
2026-07-08 14:48:17 -04:00

170 lines
7.5 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.
"""Download CMS OPPS addendum files for all available years.
Downloads Addendum A (APC weights), Addendum B (HCPCS→APC map),
and wage index files from CMS.gov for CY20142026.
Source: https://www.cms.gov/medicare/payment/prospective-payment-systems/
hospital-outpatient/addendum-a-b-updates
Usage:
uv run python dev/scripts/download_opps_files.py
uv run python dev/scripts/download_opps_files.py --year 2026
"""
from __future__ import annotations
import argparse
import time
from pathlib import Path
import httpx
ROOT = Path(__file__).resolve().parents[2]
OPPS_DIR = ROOT / "data" / "cms" / "opps"
USER_AGENT = "stack-opps-ingest/1.0"
# ---------------------------------------------------------------------------
# CMS OPPS download URLs by year
# ---------------------------------------------------------------------------
# CY2021+ : CMS publishes combined addenda ZIPs at
# https://www.cms.gov/files/zip/{year}-nfrm-opps-addenda.zip
#
# CY2014-2020 : purged from cms.gov in the 2023 site restructure, recovered
# from the Internet Archive Wayback Machine. The `web/{ts}id_/{url}` form
# returns the raw archived ZIP — the `id_` suffix is essential; without it
# Wayback serves an HTML viewer page instead of the binary.
#
# IMPORTANT — must be the COMBINED "Addenda" ZIP, which contains the real
# "Addendum B - Final OPPS Payment by HCPCS Code" (Relative Weight + Payment
# Rate columns). Do NOT point at the "Data Addendum B / 2-Times-Rule" file:
# that one carries only SI/APC/Comment-Indicator (used for geometric-mean-cost
# development) and has NO payment rate, so nothing reconciles. 2014/2015 ship
# one combined "Addenda.zip"; 2016-2020 an "...OPPS-FR-Addenda.zip". CY2016's
# only surviving snapshot is 2015-vintage (2025 snapshots 302), but it serves
# the real Addendum B. 2020 is the NFRM addenda, computed with CY2020's
# as-published CF ($80.784); CMS later cited a corrected $80.793 — see the note
# in opps.rules. (The 2019 URL is lowercased — the snapshot's exact casing.
# If a timestamp goes stale, find alternates via
# web.archive.org/cdx/search/cdx?url=<original>.)
OPPS_ADDENDA_URLS: dict[str, str] = {
"2026": "https://www.cms.gov/files/zip/2026-nfrm-opps-addenda.zip",
"2025": "https://www.cms.gov/files/zip/2025-nfrm-opps-addenda.zip",
"2024": "https://www.cms.gov/files/zip/2024-nfrm-opps-addenda.zip",
"2023": "https://www.cms.gov/files/zip/2023-nfrm-opps-addenda.zip",
"2022": "https://www.cms.gov/files/zip/2022-nfrm-opps-addenda.zip",
"2021": "https://www.cms.gov/files/zip/2021-nfrm-opps-addenda.zip",
# CY2014-2020 recovered from the Wayback Machine (combined addenda; note above).
"2020": "https://web.archive.org/web/20250630084418id_/https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1717-FC-2020-OPPS-Addenda.zip",
"2019": "https://web.archive.org/web/20250703013519id_/https://www.cms.gov/medicare/medicare-fee-for-service-payment/hospitaloutpatientpps/downloads/cms-1695-fc-2019-opps-fr-addenda.zip",
"2018": "https://web.archive.org/web/20250630084406id_/https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1678-FC-2018-OPPS-FR-Addenda.zip",
"2017": "https://web.archive.org/web/20250630084507id_/https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1656-FC-2017-OPPS-FR-Addenda.zip",
# CY2016 combined addenda: only a 2015-vintage snapshot survives (the 2025
# snapshots 302); this timestamp returns the real Addendum B (payment).
"2016": "https://web.archive.org/web/20151105123722id_/https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1633-FC-2016-OPPS-FR-Addenda.zip",
"2015": "https://web.archive.org/web/20250630084359id_/https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1613-FC-Addenda.zip",
"2014": "https://web.archive.org/web/20250630084534id_/https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1601-FC-Addenda.zip",
}
# Supporting files (cost stats, impacts, etc.)
OPPS_SUPPORTING_URLS: dict[str, dict[str, str]] = {
"2026": {
"cost_stats": "https://www.cms.gov/files/zip/2026-nfrm-opps-cost-statistics-files.zip",
"impacts": "https://www.cms.gov/files/zip/2026-nfrm-opps-facility-specific-impacts.zip",
"2x_rule": "https://www.cms.gov/files/zip/2026-nfrm-opps-data-addendum-b-2-times-rule.zip",
},
}
def download_file(url: str, dest: Path, *, retries: int = 3) -> bool:
"""Download a file if it doesn't already exist.
Retries on transport errors and 5xx — the Wayback Machine (the source
for CY2014-2020) rate-limits and 5xx's under load.
"""
if dest.exists():
print(f" EXISTS {dest.name}")
return True
print(f" GET {dest.name} ...", end="", flush=True)
for attempt in range(1, retries + 1):
try:
resp = httpx.get(
url,
headers={"User-Agent": USER_AGENT},
timeout=120,
follow_redirects=True,
)
if resp.status_code == 200:
dest.write_bytes(resp.content)
print(f" {len(resp.content) / 1024 / 1024:.1f} MB")
return True
if resp.status_code < 500 or attempt == retries:
print(f" HTTP {resp.status_code}")
return False
except Exception as e:
if attempt == retries:
print(f" ERROR: {e}")
return False
time.sleep(2 * attempt) # linear backoff before the next attempt
return False
def main() -> None:
parser = argparse.ArgumentParser(description="Download CMS OPPS files")
parser.add_argument("--year", help="Download only this year")
args = parser.parse_args()
OPPS_DIR.mkdir(parents=True, exist_ok=True)
urls = OPPS_ADDENDA_URLS
if args.year:
urls = {k: v for k, v in urls.items() if k == args.year}
total = 0
downloaded = 0
for year, url in sorted(urls.items()):
year_dir = OPPS_DIR / year
year_dir.mkdir(exist_ok=True)
print(f"\n--- CY{year} ---")
total += 1
dest = year_dir / f"opps_{year}_addenda.zip"
if download_file(url, dest):
downloaded += 1
time.sleep(0.5)
# Supporting files (if defined)
for name, surl in OPPS_SUPPORTING_URLS.get(year, {}).items():
total += 1
sdest = year_dir / f"opps_{year}_{name}.zip"
if download_file(surl, sdest):
downloaded += 1
time.sleep(0.5)
print(f"\n{'=' * 50}")
print(f"Downloaded: {downloaded}/{total} files")
print(f"Location: {OPPS_DIR}")
if set(OPPS_ADDENDA_URLS) != set(str(y) for y in range(2014, 2027)):
missing = sorted(
set(str(y) for y in range(2014, 2027)) - set(OPPS_ADDENDA_URLS)
)
print(f"\nMissing years (purged from CMS): {', '.join(missing)}")
print(" These may be recoverable from the Wayback Machine.")
print("\nInventory:")
for year_dir in sorted(OPPS_DIR.iterdir()):
if year_dir.is_dir():
files = list(year_dir.glob("*.zip"))
if files:
total_size = sum(f.stat().st_size for f in files)
print(
f" {year_dir.name}: {len(files)} files "
f"({total_size / 1024 / 1024:.1f} MB)"
)
if __name__ == "__main__":
main()