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(
|
def _run_pricer(
|
||||||
name: str,
|
name: str,
|
||||||
*,
|
*,
|
||||||
@@ -105,9 +146,9 @@ def _run_pricer(
|
|||||||
if year is None:
|
if year is None:
|
||||||
results = reconcile_all_years(pricer, con, tolerance_cents=tolerance_cents)
|
results = reconcile_all_years(pricer, con, tolerance_cents=tolerance_cents)
|
||||||
if not results:
|
if not results:
|
||||||
|
hint = _DATA_HINTS.get(name, "the required source tables")
|
||||||
typer.echo(
|
typer.echo(
|
||||||
f"No years available for {name}. "
|
f"No years available for {name}. Load {hint} first.",
|
||||||
f"Load pfs.rvu / pfs.gpci / pfs.carrier_locality first.",
|
|
||||||
err=True,
|
err=True,
|
||||||
)
|
)
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ registry without knowing about specific systems.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from rec.base import Pricer
|
from rec.base import Pricer
|
||||||
|
from rec.pricers.opps import OppsPricer
|
||||||
from rec.pricers.pfs import PfsPricer
|
from rec.pricers.pfs import PfsPricer
|
||||||
|
|
||||||
PRICERS: dict[str, type[Pricer]] = {
|
PRICERS: dict[str, type[Pricer]] = {
|
||||||
PfsPricer.system: PfsPricer,
|
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")
|
out = str(tmp_path / "report.md")
|
||||||
result = runner.invoke(app, ["pfs", "--year", "2025", "-o", out])
|
result = runner.invoke(app, ["pfs", "--year", "2025", "-o", out])
|
||||||
assert result.exit_code == 0
|
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):
|
def test_pfs_help(self):
|
||||||
assert runner.invoke(app, ["pfs", "--help"]).exit_code == 0
|
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