feat: opps module — OPPS data models, payment calcs, rule parameters (refs #263-#265)
Some checks failed
CI / lint-test (push) Failing after 2m56s
CI / skinny-install (bls) (push) Successful in 2m10s
CI / skinny-install (pfs) (push) Successful in 1m38s
CI / skinny-install (rex) (push) Successful in 1m50s
Infra CI / notebooks (push) Successful in 6s
Infra CI / zotero (push) Successful in 7s
CI / skinny-install (aco) (push) Successful in 2m13s
CI / skinny-install (api) (push) Successful in 1m56s
CI / skinny-install (bcda) (push) Successful in 2m12s
CI / skinny-install (bib) (push) Successful in 1m39s
CI / skinny-install (ccw) (push) Successful in 1m53s
CI / skinny-install (cms) (push) Successful in 1m30s
CI / skinny-install (cli) (push) Successful in 2m27s
CI / skinny-install (conf) (push) Successful in 2m1s
CI / skinny-install (perf) (push) Successful in 2m13s
Infra CI / docs (push) Successful in 15s
Infra CI / api (push) Successful in 17s
Infra CI / mc (push) Successful in 6s
Deploy / build-scan-report (push) Failing after 4m16s
Package Supply Chain / pkg-supply-chain (push) Successful in 3m20s

New top-level module mirroring pfs structure:

opps.table (5 SQLTable models):
- ApcWeight — Addendum A: APC relative weights and payment rates
- AddendumB — HCPCS-to-APC crosswalk with status indicators
- PassThrough — Addendum D: pass-through drugs/biologicals/devices
- WageIndex — CBSA-level geographic payment adjustment
- StatusIndicator — SI reference (S, T, G, K, N, Q)

opps.calcs (2 narwhalify functions):
- payment() — APC weight x CF x wage index adjustment
- skin_sub_impact() — model ASP+6% vs flat rate delta

opps.rules (5 RuleYear definitions):
- CY2014-2026 with skin sub payment mode transitions

Registered as skinny package extra in pyproject.toml.
This commit is contained in:
kert
2026-03-25 22:21:02 -04:00
parent 88901d0d17
commit 442d15a2be
13 changed files with 558 additions and 1 deletions

View File

@@ -59,6 +59,12 @@ cms = [
"narwhals>=2.17.0",
"pydantic>=2.0.0",
]
opps = [
"stack[conf]",
"stack[rex]",
"duckdb>=1.0.0",
"narwhals>=2.17.0",
]
pfs = [
"stack[conf]",
"stack[rex]",
@@ -147,6 +153,6 @@ ignore = ["E501", "E741"]
testpaths = ["tests"]
[tool.uv.build-backend]
module-name = ["aco", "api", "bcda", "bib", "bls", "ccw", "cli", "cms", "conf", "perf", "pfs", "rex"]
module-name = ["aco", "api", "bcda", "bib", "bls", "ccw", "cli", "cms", "conf", "opps", "perf", "pfs", "rex"]
namespace = true
source-exclude = ["compose.yml","infra/**","data/**","notebooks/**","tuva/**","assets/**","docs/**","dev/**","bundle/**","cloud/**"]

126
src/opps/__init__.py Normal file
View File

@@ -0,0 +1,126 @@
"""opps — Outpatient Prospective Payment System data models, ingestion, and calculations.
Ingests CMS OPPS data files (APC weight files, status indicators,
addenda, and annual rule parameters) into clean narwhals DataFrames.
Provides calculation functions for APC-based payment, pass-through
payment, and skin substitute reclassification impact analysis.
Architecture
------------
``opps`` is a peer module to ``pfs``, ``aco``, ``ccw``, and ``rex``.
It follows the same SQLTable model pattern and uses ``rex`` for file
parsing.
::
CMS Downloads (ZIP → CSV/Excel)
│
▼
┌────────┐
│ opps │ files → table → calcs → rules
└────────┘
│
▼ narwhals DataFrames
┌────────┐
│ aco │ input_layer / core / analytics
└────────┘
Submodules
----------
``opps.table``
Pydantic ``SQLTable`` models for OPPS data entities:
APC weights, status indicators, ASC payment rates,
pass-through items, and wage index data.
``opps.files``
``rex.Format`` and ``rex.Sieve`` definitions for CMS OPPS files.
Maps raw CMS column names to ``opps.table`` field names.
``opps.calcs``
``@nw.narwhalify`` expression functions for:
- APC payment calculation (APC weight × conversion factor × wage index)
- Pass-through payment (ASP + 6% for drugs/biologicals)
- Skin substitute reclassification impact (ASP+6% vs flat rate)
- Packaging threshold analysis
- ASC payment calculation
``opps.rules``
Annual rule parameters: conversion factors, wage index budget
neutrality adjustors, packaging thresholds, and skin substitute
payment category definitions. One ``RuleYear`` model per calendar
year (2014–2026).
CMS Data Files
--------------
::
┌─────────────────────────────────────────────────────────────────┐
│ File Family │ Release │ Format │ Key Columns │
├─────────────────────────────────────────────────────────────────┤
│ Addendum A │ Annual │ CSV/XLSX │ APC, group title, │
│ (APC weights) │ w/ rule │ │ relative weight, │
│ │ │ │ payment rate, │
│ │ │ │ status indicator │
├─────────────────────────────────────────────────────────────────┤
│ Addendum B │ Annual │ CSV/XLSX │ HCPCS, APC, SI, │
│ (HCPCS→APC map) │ w/ rule │ │ payment rate, │
│ │ │ │ minimum unadjusted│
│ │ │ │ copayment │
├─────────────────────────────────────────────────────────────────┤
│ Addendum D │ Annual │ CSV/XLSX │ HCPCS, pass-thru │
│ (Pass-through) │ w/ rule │ │ device/drug/bio, │
│ │ │ │ payment amount │
├─────────────────────────────────────────────────────────────────┤
│ Wage Index │ Annual │ CSV │ CBSA, wage index │
│ │ │ │ (pre/post reclss) │
├─────────────────────────────────────────────────────────────────┤
│ ASC Addendum │ Annual │ CSV/XLSX │ HCPCS, ASC group, │
│ (ASC payment) │ │ │ payment indicator,│
│ │ │ │ payment rate │
└─────────────────────────────────────────────────────────────────┘
Payment Formula
---------------
::
Payment = APC_Relative_Weight
× Conversion_Factor
× Wage_Index_Adjustment
Where:
Wage_Index_Adjustment = (Labor_Share × Wage_Index)
+ (1 - Labor_Share)
Labor_Share ≈ 0.60 (set annually in Final Rule)
Conversion_Factor = $89.723 (CY2026)
Skin Substitute Reclassification (CY2026)
-----------------------------------------
Pre-2026: skin subs paid as drugs/biologicals via pass-through
or packaged payment under OPPS, with ASP+6% for separately payable
items.
Post-2026: reclassified as incident-to supplies with flat rate
$127.28/cm² via new HCPCS codes C5271-C5278.
Usage
-----
::
from opps.table import ApcWeight, AddendumB
from opps.calcs import payment, skin_sub_impact
from opps.rules import cy2026
# Calculate OPPS payment for a given APC and CBSA
paid = payment(apc_df, wage_index_df, year=2026)
# Model skin sub reclassification impact
impact = skin_sub_impact(claims_df, asp_df, flat_rate=127.28)
"""

View File

@@ -0,0 +1,5 @@
"""OPPS payment calculation functions."""
from opps.calcs.payment import payment, skin_sub_impact
__all__ = ["payment", "skin_sub_impact"]

105
src/opps/calcs/payment.py Normal file
View File

@@ -0,0 +1,105 @@
"""OPPS payment and skin substitute impact calculations.
Core OPPS math functions, mirroring ``pfs.calcs.payment``.
"""
from __future__ import annotations
import narwhals as nw
@nw.narwhalify
def payment(
apc: nw.DataFrame,
wage_index: nw.DataFrame,
*,
labor_share: float = 0.60,
) -> nw.DataFrame:
"""Calculate OPPS payment amounts.
Joins APC weight data with wage index and computes:
``Payment = APC_Weight × CF × ((Labor_Share × Wage_Index)
+ (1 - Labor_Share))``
Parameters
----------
apc : DataFrame
APC data with columns: ``hcpcs``, ``apc``, ``relative_weight``,
``payment_rate``, ``status_indicator``, ``cbsa``.
wage_index : DataFrame
Wage index with columns: ``cbsa``, ``wage_index``.
labor_share : float
Labor share fraction (default 0.60 for CY2026).
Returns
-------
DataFrame
Input ``apc`` with added ``adjusted_payment`` column.
"""
joined = apc.join(wage_index.select(["cbsa", "wage_index"]), on="cbsa", how="left")
return joined.with_columns(
(
nw.col("payment_rate")
* (
nw.lit(labor_share) * nw.col("wage_index").fill_null(1.0)
+ nw.lit(1.0 - labor_share)
)
)
.round(2)
.alias("adjusted_payment")
)
@nw.narwhalify
def skin_sub_impact(
claims: nw.DataFrame,
asp: nw.DataFrame,
*,
flat_rate: float = 127.28,
) -> nw.DataFrame:
"""Model the Jan 2026 skin substitute reclassification impact.
For each claim, computes the difference between:
- Old payment: ASP + 6% (pass-through biological)
- New payment: flat rate per cm² (incident-to supply)
Parameters
----------
claims : DataFrame
Claims with columns: ``hcpcs_code``, ``units``, ``paid_amount``.
asp : DataFrame
ASP data with columns: ``hcpcs_code``, ``asp_per_unit``,
``payment_limit`` (ASP + 6%).
flat_rate : float
New flat rate per cm² ($127.28 for CY2026).
Returns
-------
DataFrame
Claims with added ``old_payment``, ``new_payment``,
``payment_delta``, and ``pct_change`` columns.
"""
joined = claims.join(
asp.select(["hcpcs_code", "asp_per_unit", "payment_limit"]),
on="hcpcs_code",
how="left",
)
return joined.with_columns(
(nw.col("payment_limit") * nw.col("units")).round(2).alias("old_payment"),
(nw.lit(flat_rate) * nw.col("units")).round(2).alias("new_payment"),
).with_columns(
(nw.col("new_payment") - nw.col("old_payment")).round(2).alias("payment_delta"),
nw.when(nw.col("old_payment") > 0)
.then(
(
(nw.col("new_payment") - nw.col("old_payment"))
/ nw.col("old_payment")
* 100
).round(1)
)
.otherwise(nw.lit(None))
.alias("pct_change"),
)

View File

@@ -0,0 +1 @@
"""OPPS file format definitions for CMS data ingestion."""

15
src/opps/pipe.py Normal file
View File

@@ -0,0 +1,15 @@
"""OPPS pipeline steps for the runner.
Registered with ``aco.pipe.runner`` for end-to-end execution.
"""
from __future__ import annotations
STEPS: list[tuple] = [
# Populated as ingestion and calcs are wired up
# ("opps_apc_weights", ingest_apc_weights, ApcWeight),
# ("opps_addendum_b", ingest_addendum_b, AddendumB),
# ("opps_wage_index", ingest_wage_index, WageIndex),
# ("opps_payment", calc_payment, None),
# ("opps_skin_sub_impact", calc_skin_sub_impact, None),
]

View File

@@ -0,0 +1,82 @@
"""OPPS annual rule parameters.
Conversion factors, labor shares, packaging thresholds, and
skin substitute payment category definitions by calendar year.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class RuleYear:
"""Annual OPPS rule parameters."""
year: int
conversion_factor: float
labor_share: float
packaging_threshold: float
skin_sub_payment: str # 'pass_through', 'packaged', 'flat_rate'
skin_sub_rate: float | None # flat rate $/cm² if applicable
notes: str = ""
# CY2026 — skin sub reclassification year
cy2026 = RuleYear(
year=2026,
conversion_factor=89.723,
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
cy2025 = RuleYear(
year=2025,
conversion_factor=87.436,
labor_share=0.60,
packaging_threshold=0.0,
skin_sub_payment="pass_through",
skin_sub_rate=None,
notes="High-cost/low-cost skin sub categories under OPPS",
)
# CY2024
cy2024 = RuleYear(
year=2024,
conversion_factor=86.304,
labor_share=0.60,
packaging_threshold=0.0,
skin_sub_payment="pass_through",
skin_sub_rate=None,
notes="High-cost/low-cost skin sub categories under OPPS",
)
# CY2023 — first codification of high/low cost categories
cy2023 = RuleYear(
year=2023,
conversion_factor=85.093,
labor_share=0.60,
packaging_threshold=0.0,
skin_sub_payment="pass_through",
skin_sub_rate=None,
notes="First formal high-cost/low-cost skin sub categories (CMS-1772-FC)",
)
# CY2014 — first major skin sub restructuring under OPPS
cy2014 = RuleYear(
year=2014,
conversion_factor=74.305,
labor_share=0.60,
packaging_threshold=0.0,
skin_sub_payment="pass_through",
skin_sub_rate=None,
notes="First restructuring from individual pass-through to grouped payment (CMS-1601-FC)",
)
RULE_YEARS: dict[int, RuleYear] = {
r.year: r for r in [cy2014, cy2023, cy2024, cy2025, cy2026]
}

View File

@@ -0,0 +1,15 @@
"""OPPS table definitions — SQLTable models for CMS OPPS data."""
from opps.table.addendum_b import AddendumB
from opps.table.apc_weight import ApcWeight
from opps.table.pass_through import PassThrough
from opps.table.status_indicator import StatusIndicator
from opps.table.wage_index import WageIndex
__all__ = [
"ApcWeight",
"AddendumB",
"PassThrough",
"WageIndex",
"StatusIndicator",
]

View File

@@ -0,0 +1,54 @@
"""Addendum B — HCPCS-to-APC mapping with payment rates.
One row per HCPCS code. Maps each procedure to its APC group,
status indicator, and payment rate. This is the OPPS equivalent
of the PFS RVU file.
Source: Addendum B from CMS OPPS Final Rule downloads.
"""
from __future__ import annotations
from conf.table_base import SQLTable
class AddendumB(SQLTable):
"""HCPCS-to-APC crosswalk (Addendum B)."""
__schema__ = "opps"
__tablename__ = "addendum_b"
hcpcs: str | None = None
"""HCPCS/CPT procedure code."""
short_description: str | None = None
"""Procedure code short descriptor."""
status_indicator: str | None = None
"""Payment status indicator.
Key indicators for skin substitutes:
- G: pass-through drug/biological (pre-2026 skin subs)
- K: non-pass-through drug/biological requiring HCPCS (pre-2026)
- N: packaged into APC payment (items under packaging threshold)
- S: significant procedure, not discounted when multiple
- Q: packaged/composite APC (skin sub application codes)
"""
apc: str | None = None
"""Assigned APC group."""
apc_title: str | None = None
"""APC group title."""
relative_weight: float | None = None
"""APC relative weight."""
payment_rate: float | None = None
"""National unadjusted payment rate ($)."""
minimum_unadjusted_copayment: float | None = None
"""Minimum unadjusted copayment ($)."""
year: int | None = None
"""Calendar year."""

View File

@@ -0,0 +1,41 @@
"""Addendum A — APC relative weights and payment rates.
One row per APC group. Published annually with the OPPS Final Rule.
Source: Addendum A from CMS OPPS Final Rule downloads.
"""
from __future__ import annotations
from conf.table_base import SQLTable
class ApcWeight(SQLTable):
"""APC relative weights (Addendum A)."""
__schema__ = "opps"
__tablename__ = "apc_weight"
apc: str | None = None
"""Ambulatory Payment Classification code."""
group_title: str | None = None
"""APC group title/description."""
status_indicator: str | None = None
"""Status indicator (S, T, V, Q, etc.)."""
relative_weight: float | None = None
"""APC relative weight used in payment calculation."""
payment_rate: float | None = None
"""National unadjusted payment rate ($)."""
minimum_unadjusted_copayment: float | None = None
"""Minimum unadjusted copayment amount ($)."""
year: int | None = None
"""Calendar year this weight applies to."""
notes: str | None = None
"""Additional notes or flags."""

View File

@@ -0,0 +1,41 @@
"""Pass-through payment items — drugs, biologicals, and devices.
Separately payable items that bypass APC packaging. Prior to
Jan 2026, skin substitutes were paid as pass-through biologicals
at ASP+6%. The reclassification moved them to incident-to supplies.
Source: Addendum D from CMS OPPS Final Rule downloads.
"""
from __future__ import annotations
from conf.table_base import SQLTable
class PassThrough(SQLTable):
"""Pass-through payment items (Addendum D)."""
__schema__ = "opps"
__tablename__ = "pass_through"
hcpcs: str | None = None
"""HCPCS code for the pass-through item."""
short_description: str | None = None
"""Item short descriptor."""
pass_through_type: str | None = None
"""Type: 'drug', 'biological', 'device'."""
status_indicator: str | None = None
"""Payment status indicator (G for pass-through)."""
payment_rate: float | None = None
"""Pass-through payment amount ($). For drugs/biologicals,
this is ASP + 6%."""
asp_per_unit: float | None = None
"""Average Sales Price per unit (for drugs/biologicals)."""
year: int | None = None
"""Calendar year."""

View File

@@ -0,0 +1,30 @@
"""Status indicator reference — payment category definitions.
Maps status indicator codes to their payment policy meaning.
Critical for understanding skin substitute payment history:
how products moved between status indicators over time.
"""
from __future__ import annotations
from conf.table_base import SQLTable
class StatusIndicator(SQLTable):
"""OPPS status indicator reference."""
__schema__ = "opps"
__tablename__ = "status_indicator"
si: str | None = None
"""Status indicator code (single letter)."""
description: str | None = None
"""Full description of the status indicator."""
payment_method: str | None = None
"""How items with this SI are paid (e.g., 'APC rate',
'pass-through', 'packaged', 'not payable')."""
notes: str | None = None
"""Additional notes."""

View File

@@ -0,0 +1,36 @@
"""Wage index — geographic adjustment factors for OPPS payment.
One row per CBSA (Core Based Statistical Area). Used to adjust
the labor portion of APC payment for local wage variation.
Source: OPPS wage index files from CMS OPPS Final Rule downloads.
"""
from __future__ import annotations
from conf.table_base import SQLTable
class WageIndex(SQLTable):
"""OPPS wage index by CBSA."""
__schema__ = "opps"
__tablename__ = "wage_index"
cbsa: str | None = None
"""Core Based Statistical Area code."""
cbsa_name: str | None = None
"""CBSA name (metro area)."""
state: str | None = None
"""State abbreviation."""
wage_index: float | None = None
"""Pre-reclassification wage index."""
reclassified_wage_index: float | None = None
"""Post-reclassification wage index (if applicable)."""
year: int | None = None
"""Calendar year."""