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:
kert
2026-07-08 10:38:48 -04:00
parent c7ea28d13f
commit 59272d715a
6 changed files with 359 additions and 3 deletions

View File

@@ -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)

View File

@@ -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
View 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]

View File

@@ -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

View File

@@ -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

View 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)