feat(rec): OppsPricer — reconcile OPPS APC rates vs Addendum B
Adds the second pricer to the reconciliation engine (rec was open/closed
with only PfsPricer). OppsPricer validates that our OPPS conversion
factor reproduces CMS's published Addendum B national unadjusted rate:
ground truth = opps.addendum_b.payment_rate (published, rate > 0)
calculated = relative_weight × RULES[year].conversion_factor
(APC-priced rows, weight > 0)
Rows with a weight but no published rate (packaged, SI N) surface as
calculated_only; a published rate with no weight (non-APC method) as
ground_truth_only — mirroring how PfsPricer treats carrier-priced codes.
Wage-index adjustment is intentionally out of scope: Addendum B is the
national unadjusted rate, so the check is weight × CF only.
Also:
- stack rec opps CLI subcommand (mirrors rec pfs).
- Pricer-aware "no years available" hint (was hardcoded to PFS tables).
100% coverage on the new module, CLI paths, and registry.
Note: running this against loaded Addendum B data surfaces a systematic
CF drift in opps.rules for CY2021-CY2026 (stored CFs differ from the
empirical payment_rate/weight, which matches the real published CMS
values). Tracked for a follow-up fix — the pricer is doing its job.
This commit is contained in:
@@ -77,6 +77,47 @@ def reconcile_pfs(
|
||||
)
|
||||
|
||||
|
||||
@app.command("opps")
|
||||
def reconcile_opps(
|
||||
year: Annotated[
|
||||
int | None,
|
||||
typer.Option("--year", "-y", help="Year to reconcile; omit for all."),
|
||||
] = None,
|
||||
format: Annotated[
|
||||
str,
|
||||
typer.Option("--format", "-f", help="Output format: md | json."),
|
||||
] = "md",
|
||||
output: Annotated[
|
||||
Path | None,
|
||||
typer.Option("--output", "-o", help="Write to file instead of stdout."),
|
||||
] = None,
|
||||
tolerance_cents: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--tolerance",
|
||||
"-t",
|
||||
help="Cents of tolerance for an 'exact' match.",
|
||||
),
|
||||
] = 0,
|
||||
) -> None:
|
||||
"""Reconcile OPPS calculated rates against Addendum B."""
|
||||
_run_pricer(
|
||||
"opps",
|
||||
year=year,
|
||||
format=format,
|
||||
output=output,
|
||||
tolerance_cents=tolerance_cents,
|
||||
)
|
||||
|
||||
|
||||
# Per-pricer hint for the "no years available" message — names the
|
||||
# source tables a user must load before reconciliation can run.
|
||||
_DATA_HINTS: dict[str, str] = {
|
||||
"pfs": "pfs.rvu / pfs.gpci / pfs.carrier_locality",
|
||||
"opps": "opps.addendum_b",
|
||||
}
|
||||
|
||||
|
||||
def _run_pricer(
|
||||
name: str,
|
||||
*,
|
||||
@@ -105,9 +146,9 @@ def _run_pricer(
|
||||
if year is None:
|
||||
results = reconcile_all_years(pricer, con, tolerance_cents=tolerance_cents)
|
||||
if not results:
|
||||
hint = _DATA_HINTS.get(name, "the required source tables")
|
||||
typer.echo(
|
||||
f"No years available for {name}. "
|
||||
f"Load pfs.rvu / pfs.gpci / pfs.carrier_locality first.",
|
||||
f"No years available for {name}. Load {hint} first.",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -13,10 +13,12 @@ registry without knowing about specific systems.
|
||||
from __future__ import annotations
|
||||
|
||||
from rec.base import Pricer
|
||||
from rec.pricers.opps import OppsPricer
|
||||
from rec.pricers.pfs import PfsPricer
|
||||
|
||||
PRICERS: dict[str, type[Pricer]] = {
|
||||
PfsPricer.system: PfsPricer,
|
||||
OppsPricer.system: OppsPricer,
|
||||
}
|
||||
|
||||
__all__ = ["PRICERS", "PfsPricer"]
|
||||
__all__ = ["PRICERS", "OppsPricer", "PfsPricer"]
|
||||
|
||||
119
src/rec/pricers/opps.py
Normal file
119
src/rec/pricers/opps.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""OPPS pricer — Outpatient Prospective Payment System reconciliation.
|
||||
|
||||
Ground truth: ``opps.addendum_b.payment_rate`` — the national
|
||||
unadjusted payment rate CMS publishes for each separately-payable
|
||||
APC service (one row per HCPCS × year).
|
||||
|
||||
Calculated: ``relative_weight × conversion_factor``, where the
|
||||
conversion factor comes from ``opps.rules.RULES[year]``. This is the
|
||||
OPPS equivalent of ``payment_rate = APC_weight × CF`` before any
|
||||
wage-index adjustment — i.e. exactly what Addendum B tabulates.
|
||||
|
||||
The reconciliation therefore validates that the conversion factor we
|
||||
carry in ``opps.rules`` reproduces the rate CMS published, catching CF
|
||||
drift and Addendum-B ingestion errors to the cent.
|
||||
|
||||
Wage-index adjustment (``opps.calcs.payment``) is deliberately NOT
|
||||
applied here: Addendum B is the *national unadjusted* rate, so the
|
||||
comparison is weight × CF only. Provider-level wage adjustment happens
|
||||
downstream by CBSA and is out of scope for this national reconciliation.
|
||||
|
||||
Row selection:
|
||||
- ``relative_weight > 0`` selects the APC-priced rows (status
|
||||
indicators S, T, V, J1, …). Packaged codes (SI ``N``) and
|
||||
pass-through drugs/biologicals (SI ``G``/``K``, priced from ASP)
|
||||
carry no APC weight, so they fall out of the calculated side
|
||||
naturally rather than being flagged as computation bugs.
|
||||
- A published ``payment_rate > 0`` marks the ground-truth universe.
|
||||
A weight-bearing row CMS left unpriced (packaged) shows up as
|
||||
``calculated_only``; a published rate with no weight (priced by a
|
||||
non-APC method) shows up as ``ground_truth_only``.
|
||||
|
||||
Rounding:
|
||||
``ROUND(weight × cf, 2)`` uses DuckDB's round-half-away-from-zero,
|
||||
which matches CMS's published-rate convention more closely than
|
||||
Python's round-half-to-even.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
import duckdb
|
||||
import polars as pl
|
||||
|
||||
from opps.rules import RULES
|
||||
|
||||
|
||||
class OppsPricer:
|
||||
"""OPPS reconciliation: APC weight × CF vs opps.addendum_b."""
|
||||
|
||||
system: ClassVar[str] = "opps"
|
||||
description: ClassVar[str] = (
|
||||
"Outpatient PPS — APC weight × CF vs opps.addendum_b payment_rate"
|
||||
)
|
||||
join_keys: ClassVar[list[str]] = ["year", "hcpcs"]
|
||||
compare_cols: ClassVar[list[str]] = ["payment_rate"]
|
||||
|
||||
# ── Ground truth ──────────────────────────────────────────────
|
||||
|
||||
def ground_truth(self, con: duckdb.DuckDBPyConnection, year: int) -> pl.DataFrame:
|
||||
"""CMS-published national unadjusted rates from ``addendum_b``."""
|
||||
return con.execute(
|
||||
"""
|
||||
SELECT
|
||||
year,
|
||||
hcpcs,
|
||||
payment_rate
|
||||
FROM opps.addendum_b
|
||||
WHERE year = ?
|
||||
AND hcpcs IS NOT NULL
|
||||
AND payment_rate IS NOT NULL
|
||||
AND payment_rate > 0
|
||||
""",
|
||||
[year],
|
||||
).pl()
|
||||
|
||||
# ── Calculated ────────────────────────────────────────────────
|
||||
|
||||
def calculated(self, con: duckdb.DuckDBPyConnection, year: int) -> pl.DataFrame:
|
||||
"""Calculated national rate = APC ``relative_weight`` × CF.
|
||||
|
||||
The conversion factor is sourced from ``opps.rules.RULES`` —
|
||||
the single source of truth for each year's OPPS Final Rule
|
||||
parameters. Raises ``KeyError`` when the year has no RuleYear,
|
||||
since the rate cannot be computed without a CF.
|
||||
"""
|
||||
if year not in RULES:
|
||||
raise KeyError(
|
||||
f"RuleYear missing for {year}: opps.rules.RULES has no entry "
|
||||
f"— cannot compute an APC rate without a conversion factor"
|
||||
)
|
||||
cf = RULES[year].conversion_factor
|
||||
return con.execute(
|
||||
"""
|
||||
SELECT
|
||||
year,
|
||||
hcpcs,
|
||||
ROUND(relative_weight * ?, 2) AS payment_rate
|
||||
FROM opps.addendum_b
|
||||
WHERE year = ?
|
||||
AND hcpcs IS NOT NULL
|
||||
AND relative_weight IS NOT NULL
|
||||
AND relative_weight > 0
|
||||
""",
|
||||
[cf, year],
|
||||
).pl()
|
||||
|
||||
# ── Available years ───────────────────────────────────────────
|
||||
|
||||
def years_available(self, con: duckdb.DuckDBPyConnection) -> list[int]:
|
||||
"""Years with Addendum B data AND a RuleYear (so CF is known)."""
|
||||
rows = con.execute(
|
||||
"""
|
||||
SELECT DISTINCT year FROM opps.addendum_b
|
||||
WHERE year IS NOT NULL
|
||||
ORDER BY 1
|
||||
"""
|
||||
).fetchall()
|
||||
return [int(r[0]) for r in rows if r[0] is not None and int(r[0]) in RULES]
|
||||
@@ -95,3 +95,30 @@ class TestReconcilePfs:
|
||||
out = str(tmp_path / "report.md")
|
||||
result = runner.invoke(app, ["pfs", "--year", "2025", "-o", out])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
class TestReconcileOpps:
|
||||
@patch("rec.pricers.PRICERS")
|
||||
@patch("conf.connect.duckdb")
|
||||
@patch("rec.engine.reconcile")
|
||||
@patch("rec.report.as_markdown", return_value="# OPPS Report")
|
||||
def test_single_year(self, mc_md, mc_reconcile, mc_duck, mc_pricers):
|
||||
pricer = MagicMock()
|
||||
mc_pricers.__contains__ = MagicMock(return_value=True)
|
||||
mc_pricers.__getitem__ = MagicMock(return_value=lambda: pricer)
|
||||
mc_reconcile.return_value = MagicMock()
|
||||
|
||||
result = runner.invoke(app, ["opps", "--year", "2026"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("rec.pricers.PRICERS")
|
||||
@patch("conf.connect.duckdb")
|
||||
@patch("rec.engine.reconcile_all_years", return_value={})
|
||||
def test_no_years_hints_addendum_b(self, mc_all, mc_duck, mc_pricers):
|
||||
pricer = MagicMock()
|
||||
mc_pricers.__contains__ = MagicMock(return_value=True)
|
||||
mc_pricers.__getitem__ = MagicMock(return_value=lambda: pricer)
|
||||
|
||||
result = runner.invoke(app, ["opps"])
|
||||
assert result.exit_code == 1
|
||||
assert "opps.addendum_b" in result.output
|
||||
|
||||
@@ -18,3 +18,6 @@ class TestAllHelp:
|
||||
|
||||
def test_pfs_help(self):
|
||||
assert runner.invoke(app, ["pfs", "--help"]).exit_code == 0
|
||||
|
||||
def test_opps_help(self):
|
||||
assert runner.invoke(app, ["opps", "--help"]).exit_code == 0
|
||||
|
||||
164
tests/rec/pricers/test_opps.py
Normal file
164
tests/rec/pricers/test_opps.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Tests for rec.pricers.opps.OppsPricer with a seeded in-memory DuckDB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import duckdb
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from opps.rules import RULES
|
||||
from rec.base import Pricer
|
||||
from rec.engine import reconcile
|
||||
from rec.pricers import PRICERS
|
||||
from rec.pricers.opps import OppsPricer
|
||||
|
||||
# Pick a year present in opps.rules.RULES.
|
||||
_TEST_YEAR = 2026
|
||||
assert _TEST_YEAR in RULES, f"test seed year {_TEST_YEAR} missing from RULES"
|
||||
_CF = RULES[_TEST_YEAR].conversion_factor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def con() -> duckdb.DuckDBPyConnection:
|
||||
"""DuckDB in-memory seeded with ``opps.addendum_b`` exercising the
|
||||
full match/miss matrix:
|
||||
|
||||
10060 (T): exact match — payment_rate == weight × CF
|
||||
10061 (S): 1¢ near-miss — published rate 1¢ below weight × CF
|
||||
10062 (A): ground-truth only — published rate, NULL weight
|
||||
(priced by a non-APC method → nothing to compute)
|
||||
10063 (N): calculated only — positive weight, NULL rate
|
||||
(packaged → computable but CMS publishes no rate)
|
||||
"""
|
||||
c = duckdb.connect(":memory:")
|
||||
c.execute("CREATE SCHEMA opps")
|
||||
c.execute(
|
||||
"""
|
||||
CREATE TABLE opps.addendum_b (
|
||||
hcpcs VARCHAR,
|
||||
short_description VARCHAR,
|
||||
status_indicator VARCHAR,
|
||||
apc VARCHAR,
|
||||
apc_title VARCHAR,
|
||||
relative_weight DOUBLE,
|
||||
payment_rate DOUBLE,
|
||||
minimum_unadjusted_copayment DOUBLE,
|
||||
year INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
exact_rate = round(2.0 * _CF, 2)
|
||||
near_rate = round(round(3.0 * _CF, 2) - 0.01, 2)
|
||||
c.execute(
|
||||
f"""
|
||||
INSERT INTO opps.addendum_b
|
||||
(hcpcs, status_indicator, apc, relative_weight, payment_rate, year)
|
||||
VALUES
|
||||
('10060', 'T', '5051', 2.0, {exact_rate}, {_TEST_YEAR}),
|
||||
('10061', 'S', '5052', 3.0, {near_rate}, {_TEST_YEAR}),
|
||||
('10062', 'A', NULL, NULL, 150.00, {_TEST_YEAR}),
|
||||
('10063', 'N', '5053', 1.5, NULL, {_TEST_YEAR})
|
||||
"""
|
||||
)
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_year() -> int:
|
||||
return _TEST_YEAR
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_in_PRICERS(self) -> None:
|
||||
assert "opps" in PRICERS
|
||||
assert PRICERS["opps"] is OppsPricer
|
||||
|
||||
def test_conforms_to_protocol(self) -> None:
|
||||
assert isinstance(OppsPricer(), Pricer)
|
||||
|
||||
def test_class_attributes(self) -> None:
|
||||
assert OppsPricer.system == "opps"
|
||||
assert OppsPricer.join_keys == ["year", "hcpcs"]
|
||||
assert OppsPricer.compare_cols == ["payment_rate"]
|
||||
|
||||
|
||||
class TestGroundTruth:
|
||||
def test_row_count(self, con, test_year) -> None:
|
||||
# Published rate > 0: 10060, 10061, 10062 = 3 (10063 has NULL rate).
|
||||
gt = OppsPricer().ground_truth(con, test_year)
|
||||
assert gt.height == 3
|
||||
|
||||
def test_columns(self, con, test_year) -> None:
|
||||
gt = OppsPricer().ground_truth(con, test_year)
|
||||
for col in ["year", "hcpcs", "payment_rate"]:
|
||||
assert col in gt.columns
|
||||
|
||||
def test_excludes_null_rate(self, con, test_year) -> None:
|
||||
"""10063 is packaged (NULL published rate) — not ground truth."""
|
||||
gt = OppsPricer().ground_truth(con, test_year)
|
||||
assert "10063" not in gt["hcpcs"].to_list()
|
||||
|
||||
|
||||
class TestCalculated:
|
||||
def test_row_count(self, con, test_year) -> None:
|
||||
# weight > 0: 10060, 10061, 10063 = 3 (10062 has NULL weight).
|
||||
calc = OppsPricer().calculated(con, test_year)
|
||||
assert calc.height == 3
|
||||
|
||||
def test_excludes_null_weight(self, con, test_year) -> None:
|
||||
"""10062 has no relative weight → nothing to compute."""
|
||||
calc = OppsPricer().calculated(con, test_year)
|
||||
assert "10062" not in calc["hcpcs"].to_list()
|
||||
|
||||
def test_weight_times_cf(self, con, test_year) -> None:
|
||||
calc = OppsPricer().calculated(con, test_year)
|
||||
rate = calc.filter(pl.col("hcpcs") == "10060")["payment_rate"][0]
|
||||
assert rate == round(2.0 * _CF, 2)
|
||||
|
||||
def test_returns_cents_rounded(self, con, test_year) -> None:
|
||||
calc = OppsPricer().calculated(con, test_year)
|
||||
for rate in calc["payment_rate"].to_list():
|
||||
assert rate == round(rate, 2)
|
||||
|
||||
|
||||
class TestReconcileOpps:
|
||||
def test_summary_counts(self, con, test_year) -> None:
|
||||
r = reconcile(OppsPricer(), con, test_year)
|
||||
assert r.ground_truth_rows == 3 # 10060, 10061, 10062
|
||||
assert r.calculated_rows == 3 # 10060, 10061, 10063
|
||||
assert r.matched_rows == 2 # 10060, 10061
|
||||
assert r.ground_truth_only == 1 # 10062 (published, no weight)
|
||||
assert r.calculated_only == 1 # 10063 (packaged, no rate)
|
||||
|
||||
def test_exact_and_near(self, con, test_year) -> None:
|
||||
r = reconcile(OppsPricer(), con, test_year)
|
||||
assert r.exact_matches == 1 # 10060
|
||||
assert r.near_matches == 2 # 10060 + 10061 (1¢)
|
||||
|
||||
def test_tolerance_promotes(self, con, test_year) -> None:
|
||||
r = reconcile(OppsPricer(), con, test_year, tolerance_cents=1)
|
||||
assert r.exact_matches == 2
|
||||
|
||||
def test_delta_table_contains_all_hcpcs(self, con, test_year) -> None:
|
||||
r = reconcile(OppsPricer(), con, test_year)
|
||||
assert set(r.deltas["hcpcs"].to_list()) == {
|
||||
"10060",
|
||||
"10061",
|
||||
"10062",
|
||||
"10063",
|
||||
}
|
||||
|
||||
def test_years_available(self, con, test_year) -> None:
|
||||
assert OppsPricer().years_available(con) == [test_year]
|
||||
|
||||
def test_years_available_excludes_non_rule_years(self, con) -> None:
|
||||
"""A year absent from opps.rules.RULES can't be priced."""
|
||||
con.execute(
|
||||
"INSERT INTO opps.addendum_b (hcpcs, relative_weight, payment_rate, year) "
|
||||
"VALUES ('20000', 1.0, 10.0, 1999)"
|
||||
)
|
||||
assert 1999 not in OppsPricer().years_available(con)
|
||||
|
||||
def test_missing_year_raises(self, con) -> None:
|
||||
with pytest.raises(KeyError, match="RuleYear missing"):
|
||||
OppsPricer().calculated(con, 1999)
|
||||
Reference in New Issue
Block a user