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.
170 lines
7.5 KiB
Python
170 lines
7.5 KiB
Python
"""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 CY2014–2026.
|
||
|
||
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()
|