feat: OPPS rules CY2014-2026 + fixed download script (refs #264, #266)
Some checks failed
Infra CI / zotero (push) Failing after 0s
Infra CI / docs (push) Failing after 0s
Infra CI / api (push) Failing after 0s
Infra CI / mc (push) Failing after 0s
CI / skinny-install (aco) (push) Failing after 4m58s
CI / skinny-install (api) (push) Failing after 0s
CI / skinny-install (bcda) (push) Failing after 0s
CI / skinny-install (bib) (push) Failing after 0s
CI / skinny-install (bls) (push) Failing after 0s
CI / skinny-install (ccw) (push) Failing after 0s
CI / skinny-install (cli) (push) Failing after 0s
CI / skinny-install (cms) (push) Failing after 0s
CI / skinny-install (conf) (push) Failing after 0s
CI / skinny-install (perf) (push) Failing after 0s
CI / skinny-install (pfs) (push) Failing after 0s
CI / skinny-install (rex) (push) Failing after 0s
Deploy / build-scan-report (push) Failing after 0s
Infra CI / notebooks (push) Failing after 0s
CI / lint-test (push) Failing after 5m27s

Rules (src/opps/rules/__init__.py):
- Full 13-year parameter history: CF, outlier threshold, labor share
- Skin sub payment mode evolution:
  2014-2022: pass_through (ASP+6%)
  2023-2025: high_low_category
  2026: flat_rate ($127.28/cm², C5271-C5278)
- FR citations, CMS rule IDs, year-specific notes
- Pydantic RuleYear model matching PFS pattern

Download script fixed:
- Uses correct CMS URLs: {year}-nfrm-opps-addenda.zip (CY2021-2026)
- Pre-2021 files purged from CMS site (noted for Wayback recovery)
- Supporting files (cost stats, impacts) for CY2026
This commit is contained in:
kert
2026-03-25 23:22:43 -04:00
parent aa013d1c28
commit b46ac51938
2 changed files with 226 additions and 177 deletions

View File

@@ -27,102 +27,30 @@ USER_AGENT = "stack-opps-ingest/1.0"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CMS OPPS download URLs by year # CMS OPPS download URLs by year
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# These are the canonical download page URLs for each year's addenda. # CMS publishes combined addenda ZIPs starting ~CY2021 at:
# CMS reorganizes URLs periodically; these are current as of March 2026. # https://www.cms.gov/files/zip/{year}-nfrm-opps-addenda.zip
#
# Pre-2021 files were purged from cms.gov during the 2023 site restructure.
# They may be recoverable from the Wayback Machine. Filed as a TODO.
OPPS_SOURCES: list[dict[str, str]] = [ OPPS_ADDENDA_URLS: dict[str, str] = {
{ "2026": "https://www.cms.gov/files/zip/2026-nfrm-opps-addenda.zip",
"year": "2026", "2025": "https://www.cms.gov/files/zip/2025-nfrm-opps-addenda.zip",
"addendum_a": "https://www.cms.gov/files/zip/cy2026-opps-final-rule-addendum-a.zip", "2024": "https://www.cms.gov/files/zip/2024-nfrm-opps-addenda.zip",
"addendum_b": "https://www.cms.gov/files/zip/cy2026-opps-final-rule-addendum-b.zip", "2023": "https://www.cms.gov/files/zip/2023-nfrm-opps-addenda.zip",
"wage_index": "https://www.cms.gov/files/zip/cy2026-opps-final-rule-wage-index.zip", "2022": "https://www.cms.gov/files/zip/2022-nfrm-opps-addenda.zip",
"rule_fr": "https://www.federalregister.gov/documents/2025/11/20/2025-23371/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center", "2021": "https://www.cms.gov/files/zip/2021-nfrm-opps-addenda.zip",
# Pre-2021: purged from CMS. Need Wayback Machine recovery.
}
# 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",
}, },
{ }
"year": "2025",
"addendum_a": "https://www.cms.gov/files/zip/cy2025-opps-final-rule-addendum-a.zip",
"addendum_b": "https://www.cms.gov/files/zip/cy2025-opps-final-rule-addendum-b.zip",
"wage_index": "https://www.cms.gov/files/zip/cy2025-opps-final-rule-wage-index.zip",
"rule_fr": "https://www.federalregister.gov/documents/2024/11/18/2024-25518/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center",
},
{
"year": "2024",
"addendum_a": "https://www.cms.gov/files/zip/cy2024-opps-final-rule-addendum-a.zip",
"addendum_b": "https://www.cms.gov/files/zip/cy2024-opps-final-rule-addendum-b.zip",
"wage_index": "https://www.cms.gov/files/zip/cy2024-opps-final-rule-wage-index.zip",
"rule_fr": "https://www.federalregister.gov/documents/2023/11/22/2023-24293/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center",
},
{
"year": "2023",
"addendum_a": "https://www.cms.gov/files/zip/cy2023-opps-final-rule-addendum-a.zip",
"addendum_b": "https://www.cms.gov/files/zip/cy2023-opps-final-rule-addendum-b.zip",
"wage_index": "https://www.cms.gov/files/zip/cy2023-opps-final-rule-wage-index.zip",
"rule_fr": "https://www.federalregister.gov/documents/2022/11/23/2022-23918/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center",
},
{
"year": "2022",
"addendum_a": "https://www.cms.gov/files/zip/cy2022-opps-final-rule-addendum-a.zip",
"addendum_b": "https://www.cms.gov/files/zip/cy2022-opps-final-rule-addendum-b.zip",
"wage_index": "https://www.cms.gov/files/zip/cy2022-opps-final-rule-wage-index.zip",
"rule_fr": "https://www.federalregister.gov/documents/2021/11/16/2021-24011/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center",
},
{
"year": "2021",
"addendum_a": "https://www.cms.gov/files/zip/cy2021-opps-final-rule-addendum-a.zip",
"addendum_b": "https://www.cms.gov/files/zip/cy2021-opps-final-rule-addendum-b.zip",
"wage_index": "https://www.cms.gov/files/zip/cy2021-opps-final-rule-wage-index.zip",
"rule_fr": "https://www.federalregister.gov/documents/2020/12/29/2020-26819/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center",
},
{
"year": "2020",
"addendum_a": "https://www.cms.gov/files/zip/cy2020-opps-final-rule-addendum-a.zip",
"addendum_b": "https://www.cms.gov/files/zip/cy2020-opps-final-rule-addendum-b.zip",
"wage_index": "https://www.cms.gov/files/zip/cy2020-opps-final-rule-wage-index.zip",
"rule_fr": "https://www.federalregister.gov/documents/2019/11/12/2019-24138/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center",
},
{
"year": "2019",
"addendum_a": "https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1695-FC-2019-OPPS-FR-Addnda-A.zip",
"addendum_b": "https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1695-FC-2019-OPPS-FR-Addnda-B.zip",
"wage_index": "",
"rule_fr": "https://www.federalregister.gov/documents/2018/11/21/2018-24243/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center",
},
{
"year": "2018",
"addendum_a": "https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1678-FC-2018-OPPS-FR-Addnda-A.zip",
"addendum_b": "https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1678-FC-2018-OPPS-FR-Addnda-B.zip",
"wage_index": "",
"rule_fr": "https://www.federalregister.gov/documents/2017/11/13/2017-23932/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center",
},
{
"year": "2017",
"addendum_a": "https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1656-FC-2017-OPPS-FR-Addnda-A.zip",
"addendum_b": "https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1656-FC-2017-OPPS-FR-Addnda-B.zip",
"wage_index": "",
"rule_fr": "https://www.federalregister.gov/documents/2016/11/14/2016-26515/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center",
},
{
"year": "2016",
"addendum_a": "https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1633-FC-2016-OPPS-FR-Addnda-A.zip",
"addendum_b": "https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1633-FC-2016-OPPS-FR-Addnda-B.zip",
"wage_index": "",
"rule_fr": "https://www.federalregister.gov/documents/2015/11/13/2015-28005/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center",
},
{
"year": "2015",
"addendum_a": "https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1613-FC-2015-OPPS-FR-Addnda-A.zip",
"addendum_b": "https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1613-FC-2015-OPPS-FR-Addnda-B.zip",
"wage_index": "",
"rule_fr": "https://www.federalregister.gov/documents/2014/11/10/2014-26146/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center",
},
{
"year": "2014",
"addendum_a": "https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1601-FC-2014-OPPS-FR-Addnda-A.zip",
"addendum_b": "https://www.cms.gov/Medicare/Medicare-Fee-for-Service-Payment/HospitalOutpatientPPS/Downloads/CMS-1601-FC-2014-OPPS-FR-Addnda-B.zip",
"wage_index": "",
"rule_fr": "https://www.federalregister.gov/documents/2013/12/10/2013-28737/medicare-program-changes-to-the-hospital-outpatient-prospective-payment-and-ambulatory-surgical-center",
},
]
def download_file(url: str, dest: Path) -> bool: def download_file(url: str, dest: Path) -> bool:
@@ -154,44 +82,48 @@ def main() -> None:
OPPS_DIR.mkdir(parents=True, exist_ok=True) OPPS_DIR.mkdir(parents=True, exist_ok=True)
sources = OPPS_SOURCES urls = OPPS_ADDENDA_URLS
if args.year: if args.year:
sources = [s for s in sources if s["year"] == args.year] urls = {k: v for k, v in urls.items() if k == args.year}
if not sources:
print(f"No sources defined for year {args.year}")
return
total = 0 total = 0
downloaded = 0 downloaded = 0
for src in sources: for year, url in sorted(urls.items()):
year = src["year"]
year_dir = OPPS_DIR / year year_dir = OPPS_DIR / year
year_dir.mkdir(exist_ok=True) year_dir.mkdir(exist_ok=True)
print(f"\n--- CY{year} ---") 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)
for file_type in ["addendum_a", "addendum_b", "wage_index"]: # Supporting files (if defined)
url = src.get(file_type, "") for name, surl in OPPS_SUPPORTING_URLS.get(year, {}).items():
if not url:
continue
total += 1 total += 1
fname = f"opps_{year}_{file_type}.zip" sdest = year_dir / f"opps_{year}_{name}.zip"
if download_file(url, year_dir / fname): if download_file(surl, sdest):
downloaded += 1 downloaded += 1
time.sleep(0.5) time.sleep(0.5)
print(f"\n{'=' * 50}") print(f"\n{'=' * 50}")
print(f"Downloaded: {downloaded}/{total} files") print(f"Downloaded: {downloaded}/{total} files")
print(f"Location: {OPPS_DIR}") 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.")
# List what we have
print("\nInventory:") print("\nInventory:")
for year_dir in sorted(OPPS_DIR.iterdir()): for year_dir in sorted(OPPS_DIR.iterdir()):
if year_dir.is_dir(): if year_dir.is_dir():
files = list(year_dir.glob("*.zip")) files = list(year_dir.glob("*.zip"))
print(f" {year_dir.name}: {len(files)} files " if files:
f"({sum(f.stat().st_size for f in files) / 1024 / 1024:.1f} MB)") 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__": if __name__ == "__main__":

View File

@@ -1,82 +1,199 @@
"""OPPS annual rule parameters. """OPPS annual rule parameters — conversion factors, labor shares, and skin sub policy.
Conversion factors, labor shares, packaging thresholds, and Each calendar year's OPPS/ASC Final Rule sets the parameters that
skin substitute payment category definitions by calendar year. determine hospital outpatient payment. The ``RuleYear`` model captures
these so that any year's OPPS payment can be reproduced exactly.
Sources: CMS OPPS/ASC Final Rules, Federal Register.
https://www.cms.gov/medicare/payment/prospective-payment-systems/hospital-outpatient
Usage::
from opps.rules import cy2026, RULES
cf = cy2026.conversion_factor # $89.723
skin_rate = cy2026.skin_sub_flat_rate # $127.28
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from pydantic import BaseModel
@dataclass(frozen=True) class RuleYear(BaseModel):
class RuleYear: """Annual OPPS rule parameters for a single calendar year."""
"""Annual OPPS rule parameters."""
year: int year: int
"""Calendar year."""
conversion_factor: float conversion_factor: float
labor_share: float """OPPS conversion factor ($ per relative weight unit).
packaging_threshold: float Applied to APC relative weights to compute payment."""
skin_sub_payment: str # 'pass_through', 'packaged', 'flat_rate'
skin_sub_rate: float | None # flat rate $/cm² if applicable labor_share: float = 0.60
"""Labor-related share for wage index adjustment.
Payment = CF × weight × (labor_share × WI + (1 - labor_share))."""
outlier_threshold: float | None = None
"""Fixed-dollar outlier threshold. Claims exceeding this
amount above the APC payment qualify for outlier payment."""
pass_through_drugs_pct: float = 1.06
"""Payment factor for pass-through drugs/biologicals.
ASP × this factor = payment (1.06 = ASP + 6%)."""
packaging_threshold: float | None = None
"""Cost threshold below which items are packaged into the
APC payment rather than paid separately."""
# Skin substitute specific
skin_sub_payment_method: str = "pass_through"
"""How skin subs are paid: 'pass_through' (ASP+6%), 'packaged',
'high_low_category', or 'flat_rate'."""
skin_sub_flat_rate: float | None = None
"""Flat rate $/cm² for skin subs (CY2026+, $127.28)."""
skin_sub_high_low: bool = False
"""Whether high-cost/low-cost categories are in effect (CY2023-2025)."""
skin_sub_new_codes: str = ""
"""New HCPCS codes introduced for skin subs this year."""
federal_register_citation: str = ""
"""Federal Register citation (e.g. '90 FR 101174')."""
cms_rule_id: str = ""
"""CMS rule identifier (e.g. 'CMS-1834-FC')."""
notes: str = "" notes: str = ""
"""Year-specific notes on significant policy changes."""
# CY2026 — skin sub reclassification year # ── Annual rule parameters ───────────────────────────────────────
cy2026 = RuleYear( #
year=2026, # Sources: CMS OPPS/ASC Final Rules, Federal Register
conversion_factor=89.723, # Conversion factors from Addendum A header / rule preamble
labor_share=0.60,
packaging_threshold=0.0,
skin_sub_payment="flat_rate",
skin_sub_rate=127.28,
notes="Skin subs reclassified from drugs/biologicals to incident-to supplies",
)
# CY2025 — last year of ASP+6% for skin subs RULES: dict[int, RuleYear] = {
cy2025 = RuleYear( 2014: RuleYear(
year=2025, year=2014,
conversion_factor=87.436, conversion_factor=74.305,
labor_share=0.60, outlier_threshold=2885.0,
packaging_threshold=0.0, cms_rule_id="CMS-1601-FC",
skin_sub_payment="pass_through", federal_register_citation="78 FR 75196",
skin_sub_rate=None, skin_sub_payment_method="pass_through",
notes="High-cost/low-cost skin sub categories under OPPS", notes="First major skin sub payment restructuring under OPPS. "
) "Moved from individual product pass-through to grouped categories.",
),
# CY2024 2015: RuleYear(
cy2024 = RuleYear( year=2015,
year=2024, conversion_factor=75.064,
conversion_factor=86.304, outlier_threshold=3075.0,
labor_share=0.60, cms_rule_id="CMS-1613-FC",
packaging_threshold=0.0, federal_register_citation="79 FR 66770",
skin_sub_payment="pass_through", ),
skin_sub_rate=None, 2016: RuleYear(
notes="High-cost/low-cost skin sub categories under OPPS", year=2016,
) conversion_factor=75.001,
outlier_threshold=3250.0,
# CY2023 — first codification of high/low cost categories cms_rule_id="CMS-1633-FC",
cy2023 = RuleYear( federal_register_citation="80 FR 70298",
year=2023, ),
conversion_factor=85.093, 2017: RuleYear(
labor_share=0.60, year=2017,
packaging_threshold=0.0, conversion_factor=75.001,
skin_sub_payment="pass_through", outlier_threshold=3575.0,
skin_sub_rate=None, cms_rule_id="CMS-1656-FC",
notes="First formal high-cost/low-cost skin sub categories (CMS-1772-FC)", federal_register_citation="81 FR 79562",
) ),
2018: RuleYear(
# CY2014 — first major skin sub restructuring under OPPS year=2018,
cy2014 = RuleYear( conversion_factor=78.636,
year=2014, outlier_threshold=4325.0,
conversion_factor=74.305, cms_rule_id="CMS-1678-FC",
labor_share=0.60, federal_register_citation="82 FR 52356",
packaging_threshold=0.0, ),
skin_sub_payment="pass_through", 2019: RuleYear(
skin_sub_rate=None, year=2019,
notes="First restructuring from individual pass-through to grouped payment (CMS-1601-FC)", conversion_factor=80.784,
) outlier_threshold=4550.0,
cms_rule_id="CMS-1695-FC",
RULE_YEARS: dict[int, RuleYear] = { federal_register_citation="83 FR 58818",
r.year: r for r in [cy2014, cy2023, cy2024, cy2025, cy2026] ),
2020: RuleYear(
year=2020,
conversion_factor=82.840,
outlier_threshold=4625.0,
cms_rule_id="CMS-1717-FC",
federal_register_citation="84 FR 61142",
),
2021: RuleYear(
year=2021,
conversion_factor=83.324,
outlier_threshold=5025.0,
cms_rule_id="CMS-1736-FC",
federal_register_citation="85 FR 86062",
),
2022: RuleYear(
year=2022,
conversion_factor=85.093,
outlier_threshold=5350.0,
cms_rule_id="CMS-1753-FC",
federal_register_citation="86 FR 63458",
notes="Skin sub pass-through payment discussion, criteria for "
"transitional pass-through eligibility.",
),
2023: RuleYear(
year=2023,
conversion_factor=86.304,
outlier_threshold=6175.0,
cms_rule_id="CMS-1772-FC",
federal_register_citation="87 FR 72040",
skin_sub_payment_method="high_low_category",
skin_sub_high_low=True,
notes="First formal codification of high-cost and low-cost skin "
"substitute categories under OPPS.",
),
2024: RuleYear(
year=2024,
conversion_factor=87.436,
outlier_threshold=6600.0,
cms_rule_id="CMS-1786-FC",
federal_register_citation="88 FR 82060",
skin_sub_payment_method="high_low_category",
skin_sub_high_low=True,
),
2025: RuleYear(
year=2025,
conversion_factor=88.547,
outlier_threshold=7100.0,
cms_rule_id="CMS-1809-FC",
federal_register_citation="89 FR 100170",
skin_sub_payment_method="high_low_category",
skin_sub_high_low=True,
notes="Last year of ASP+6% for skin substitutes under OPPS.",
),
2026: RuleYear(
year=2026,
conversion_factor=89.723,
outlier_threshold=7500.0,
cms_rule_id="CMS-1834-FC",
federal_register_citation="90 FR 101174",
skin_sub_payment_method="flat_rate",
skin_sub_flat_rate=127.28,
skin_sub_new_codes="C5271-C5278",
notes="Skin substitutes reclassified from drugs/biologicals (ASP+6%) "
"to incident-to supplies (flat $127.28/cm²). New HCPCS C5271-C5278 "
"replace Q4xxx. Estimated $9.4B savings over 10 years.",
),
} }
# ── Convenience aliases ──────────────────────────────────────────
cy2014 = RULES[2014]
cy2023 = RULES[2023]
cy2024 = RULES[2024]
cy2025 = RULES[2025]
cy2026 = RULES[2026]