One row per (code, effective date, kind) for becomes-payable / revalued / status-change / ends / telehealth-listed, derived from pfs.rvu (A/R/T payable, left- and right-censored at the file span) and pfs.code_event (revalued, telehealth_list), each anchored to the code's nearest kind-matched FR lineage paragraph. Effective date is 1 January of the rule year unless the event's own year is anchored only by a correction notice (bib.frlink.rule_kind), then that notice's publication date. Rows carry the family key, the year's RVU status and the decile band of non_fac_total among that year's payable codes; control_codes() returns the never-treated set (same status + band, no exposure within a window). stack pfs exposure [--code|--family] [--write] [--controls N --year Y]; notebook section 7c lists a family's exposures with FR jump links and a control-set preview. Live dry run: 6,314 rows / 4,796 codes in 1.3 s; every fixture event in #693 present (99490 2015, 99487/99489 2017, 99439 + G2058 2021, 99424 + G2064 2022, G2211 2024, G0556 2025, 99441 ends 2025).
383 lines
14 KiB
Python
383 lines
14 KiB
Python
"""pfs.exposure — the exposure calendar derived from lineage + RVU status (#693)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import sqlite3
|
|
|
|
import duckdb
|
|
import pytest
|
|
|
|
from pfs.codetables import (
|
|
EventRow,
|
|
ExposureRow,
|
|
ensure_tables,
|
|
read_exposures,
|
|
write_events,
|
|
write_exposures,
|
|
)
|
|
from pfs.exposure import (
|
|
KINDS,
|
|
build,
|
|
control_codes,
|
|
derive,
|
|
rvu_bands,
|
|
rvu_series,
|
|
)
|
|
|
|
RVU_COLS = "hcpcs VARCHAR, mod VARCHAR, description VARCHAR, status_code VARCHAR, non_fac_total DOUBLE, year INTEGER"
|
|
|
|
|
|
def _fr(code, year, kind, item_key, p_id, page=1, from_codes="", to_codes=""):
|
|
return EventRow(
|
|
code, year, kind, from_codes, to_codes, item_key, p_id, page, "fr", True, ""
|
|
)
|
|
|
|
|
|
def _rvu(code, year, kind, note=""):
|
|
return EventRow(code, year, kind, "", "", "", 0, 0, "rvu", True, note)
|
|
|
|
|
|
@pytest.fixture
|
|
def con():
|
|
c = duckdb.connect(":memory:")
|
|
ensure_tables(c)
|
|
c.execute(f"CREATE TABLE pfs.rvu ({RVU_COLS})")
|
|
rows = [
|
|
# span 2015..2026; ten filler payable codes give the deciles something to rank
|
|
*[
|
|
(f"F{i:04d}", None, "filler", "A", float(i), y)
|
|
for i in range(1, 11)
|
|
for y in range(2015, 2027)
|
|
],
|
|
# 99490 payable from the first year (left-censored) — FR says created 2015
|
|
*[("99490", None, "ccm", "A", 1.2, y) for y in range(2015, 2027)],
|
|
# 99487: B 2015-16, A from 2017; +47% revalue 2022
|
|
("99487", None, "cplx", "B", 0.0, 2015),
|
|
("99487", None, "cplx", "B", 0.0, 2016),
|
|
*[("99487", None, "cplx", "A", 2.6, y) for y in range(2017, 2022)],
|
|
*[("99487", None, "cplx", "A", 3.9, y) for y in range(2022, 2027)],
|
|
# G2058: 2020 only → ends 2021 (replaced by 99439)
|
|
("G2058", None, "ccm addl", "A", 1.05, 2020),
|
|
*[("99439", "", "ccm addl", "A", 1.1, y) for y in range(2021, 2027)],
|
|
# 99441: N through 2020, A 2021-2024, gone 2025
|
|
*[("99441", None, "phone", "N", 0.4, y) for y in range(2015, 2021)],
|
|
*[("99441", None, "phone", "A", 1.6, y) for y in range(2021, 2025)],
|
|
# 12345: A → R (both payable) in 2019, R → C (non-payable) in 2023
|
|
*[("12345", None, "x", "A", 5.0, y) for y in range(2015, 2019)],
|
|
*[("12345", None, "x", "R", 5.0, y) for y in range(2019, 2023)],
|
|
*[("12345", None, "x", "C", 0.0, y) for y in range(2023, 2027)],
|
|
# modifier rows are ignored
|
|
("99487", "26", "modifier row ignored", "A", 99.0, 2022),
|
|
]
|
|
c.executemany("INSERT INTO pfs.rvu VALUES (?,?,?,?,?,?)", rows)
|
|
write_events(
|
|
c,
|
|
"99490",
|
|
[
|
|
_fr("99490", 2015, "adopted_cpt", "DE2VH9PD", 1251, 67716),
|
|
_fr("99490", 2015, "created", "6N8FPFHF", 49, 14854),
|
|
_fr("99490", 2016, "created", "VZW3HXDA", 1181),
|
|
],
|
|
)
|
|
write_events(
|
|
c,
|
|
"99487",
|
|
[
|
|
_rvu("99487", 2017, "status_change", "B→A"),
|
|
_rvu("99487", 2022, "revalued", "+47.5%"),
|
|
_fr("99487", 2022, "crosswalk", "JE7KYBW3", 900),
|
|
],
|
|
)
|
|
write_events(
|
|
c,
|
|
"G2058",
|
|
[
|
|
_rvu("G2058", 2021, "disappeared"),
|
|
_fr("G2058", 2021, "replaced_by", "YBM4IZUS", 1578, to_codes="99439"),
|
|
],
|
|
)
|
|
write_events(
|
|
c,
|
|
"99439",
|
|
[
|
|
_rvu("99439", 2021, "appeared"),
|
|
_fr("99439", 2021, "created", "YBM4IZUS", 686),
|
|
_fr("99439", 2021, "replaces", "YBM4IZUS", 1578, from_codes="G2058"),
|
|
],
|
|
)
|
|
write_events(
|
|
c,
|
|
"99441",
|
|
[
|
|
_fr("99441", 2022, "telehealth_list", "JE7KYBW3", 555),
|
|
_fr("99441", 2023, "telehealth_list", "MZ24MX5S", 336),
|
|
_fr("99441", 2023, "telehealth_list", "PETM225D", 458),
|
|
_fr("99441", 2025, "deleted", "XFGGRBDH", 489),
|
|
_fr("99441", 2025, "bundled", "JJ6AM5HJ", 765),
|
|
# a lone correction-only anchor: only a correction notice mentions this
|
|
_fr("99441", 2021, "created", "CORR2021", 7),
|
|
],
|
|
)
|
|
write_events(c, "12345", [])
|
|
yield c
|
|
c.close()
|
|
|
|
|
|
class _Store:
|
|
def __init__(self):
|
|
self.con = sqlite3.connect(":memory:")
|
|
self.con.row_factory = sqlite3.Row
|
|
self.con.executescript(
|
|
"CREATE TABLE items (key TEXT PRIMARY KEY, title TEXT, date_published TEXT);"
|
|
"CREATE TABLE fr_anchors (item_key TEXT, p_id INTEGER, page INTEGER, ordinal INTEGER, text TEXT);"
|
|
)
|
|
self.con.executemany(
|
|
"INSERT INTO items VALUES (?,?,?)",
|
|
[
|
|
("DE2VH9PD", "Medicare Program; CY 2015 PFS Final Rule", "2014-11-13"),
|
|
("6N8FPFHF", "Medicare Program; CY 2015 PFS; Correction", "2015-03-20"),
|
|
("VZW3HXDA", "CY 2016 PFS Proposed Rule", "2015-07-15"),
|
|
("JE7KYBW3", "CY 2022 PFS Final Rule", "2021-11-19"),
|
|
("YBM4IZUS", "CY 2021 PFS Final Rule", "2020-12-28"),
|
|
("MZ24MX5S", "CY 2023 PFS Final Rule", "2022-11-18"),
|
|
("PETM225D", "CY 2023 PFS Proposed Rule", "2022-07-29"),
|
|
("XFGGRBDH", "CY 2025 PFS Proposed Rule", "2024-07-31"),
|
|
("JJ6AM5HJ", "CY 2025 PFS Final Rule", "2024-12-09"),
|
|
("CORR2021", "CY 2021 PFS Final Rule; Correction", "2021-01-19"),
|
|
],
|
|
)
|
|
|
|
def _con(self):
|
|
return self.con
|
|
|
|
def get(self, key):
|
|
row = self.con.execute("SELECT * FROM items WHERE key=?", (key,)).fetchone()
|
|
if row is None:
|
|
raise KeyError(key)
|
|
|
|
class _I:
|
|
title = row["title"]
|
|
date_published = row["date_published"]
|
|
|
|
return _I()
|
|
|
|
def close(self):
|
|
self.con.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def store():
|
|
s = _Store()
|
|
yield s
|
|
s.close()
|
|
|
|
|
|
def _by(rows, code, kind=None):
|
|
return [r for r in rows if r.code == code and (kind is None or r.kind == kind)]
|
|
|
|
|
|
class TestSeriesAndBands:
|
|
def test_series_ignores_modifier_rows(self, con):
|
|
s = rvu_series(con)
|
|
assert [y for y, _, _ in s["99487"]] == list(range(2015, 2027))
|
|
assert all(t != 99.0 for _, _, t in s["99487"])
|
|
|
|
def test_bands_are_deciles_among_payable_codes(self, con):
|
|
s = rvu_series(con)
|
|
b = rvu_bands(s)
|
|
# ten fillers 1..10 plus the real codes; the cheapest is band 1, dearest band 10
|
|
assert b[(2020, "F0001")] == 1
|
|
# 14 payable codes in 2020; 12345 (5.0) sorts 8th (ties break on code) → 1 + 7*10//14
|
|
assert b[(2020, "12345")] == 6
|
|
assert b[(2020, "F0010")] == 10
|
|
assert (2016, "99487") not in b # B status is not payable → no band
|
|
|
|
def test_ties_share_a_band(self):
|
|
series = {c: [(2020, "A", 1.0)] for c in ("A0001", "A0002", "A0003")}
|
|
series["B0001"] = [(2020, "A", 2.0)]
|
|
b = rvu_bands(series)
|
|
assert b[(2020, "A0001")] == b[(2020, "A0002")] == b[(2020, "A0003")] == 1
|
|
assert b[(2020, "B0001")] == 8 # index 3 of 4 → 1 + 30//4
|
|
|
|
|
|
class TestDerive:
|
|
def test_kinds_are_closed(self):
|
|
assert KINDS == (
|
|
"becomes-payable",
|
|
"revalued",
|
|
"status-change",
|
|
"ends",
|
|
"telehealth-listed",
|
|
)
|
|
|
|
def test_becomes_payable_from_status_flip_and_appearance(self, con, store):
|
|
rows = build(con, store)
|
|
r = _by(rows, "99487", "becomes-payable")
|
|
assert (
|
|
len(r) == 1
|
|
and r[0].year == 2017
|
|
and r[0].prior_status == "B"
|
|
and r[0].status == "A"
|
|
)
|
|
assert r[0].effective_date == dt.date(2017, 1, 1)
|
|
r = _by(rows, "99439", "becomes-payable")
|
|
assert len(r) == 1 and r[0].year == 2021 and r[0].prior_status == ""
|
|
# anchor: kind-matched (created/replaces) from the same year, lowest p_id
|
|
assert (r[0].item_key, r[0].p_id, r[0].anchor_kind) == (
|
|
"YBM4IZUS",
|
|
686,
|
|
"created",
|
|
)
|
|
|
|
def test_left_censored_first_year_needs_fr_evidence(self, con, store):
|
|
rows = build(con, store)
|
|
r = _by(rows, "99490", "becomes-payable")
|
|
assert len(r) == 1 and r[0].year == 2015
|
|
# the final rule's adopted_cpt anchor wins over the correction notice
|
|
assert r[0].item_key == "DE2VH9PD" and r[0].correction is False
|
|
assert r[0].effective_date == dt.date(2015, 1, 1)
|
|
# filler codes are payable in 2015 too but have no FR evidence → no row
|
|
assert not _by(rows, "F0001")
|
|
|
|
def test_ends_on_disappearance_and_on_non_payable_flip(self, con, store):
|
|
rows = build(con, store)
|
|
r = _by(rows, "G2058", "ends")
|
|
assert (
|
|
len(r) == 1
|
|
and r[0].year == 2021
|
|
and r[0].prior_status == "A"
|
|
and r[0].status == ""
|
|
)
|
|
assert (r[0].item_key, r[0].anchor_kind, r[0].p_id) == (
|
|
"YBM4IZUS",
|
|
"replaced_by",
|
|
1578,
|
|
)
|
|
r = _by(rows, "99441", "ends")
|
|
assert len(r) == 1 and r[0].year == 2025 and r[0].anchor_kind == "deleted"
|
|
r = _by(rows, "12345", "ends")
|
|
assert (
|
|
len(r) == 1
|
|
and r[0].year == 2023
|
|
and r[0].prior_status == "R"
|
|
and r[0].status == "C"
|
|
)
|
|
assert r[0].item_key == "" and r[0].p_id == 0 # no FR mention at all
|
|
|
|
def test_status_change_only_between_payable_statuses(self, con, store):
|
|
rows = build(con, store)
|
|
r = _by(rows, "12345", "status-change")
|
|
assert [(x.year, x.prior_status, x.status) for x in r] == [(2019, "A", "R")]
|
|
assert not _by(
|
|
rows, "99441", "status-change"
|
|
) # N→A is becomes-payable, not status-change
|
|
|
|
def test_revalued_carries_delta_and_nearest_anchor(self, con, store):
|
|
rows = build(con, store)
|
|
r = _by(rows, "99487", "revalued")
|
|
assert len(r) == 1 and r[0].year == 2022
|
|
assert r[0].delta_pct == pytest.approx(0.475)
|
|
assert r[0].item_key == "JE7KYBW3" and r[0].anchor_kind == "crosswalk"
|
|
|
|
def test_telehealth_listed_once_per_run_of_years(self, con, store):
|
|
rows = build(con, store)
|
|
r = _by(rows, "99441", "telehealth-listed")
|
|
assert [x.year for x in r] == [2022] # 2023 continues the 2022 run
|
|
assert r[0].item_key == "JE7KYBW3" and r[0].p_id == 555
|
|
|
|
def test_correction_only_anchor_moves_the_effective_date(self, con, store):
|
|
rows = build(con, store)
|
|
r = _by(rows, "99441", "becomes-payable")
|
|
assert len(r) == 1 and r[0].year == 2021
|
|
assert r[0].correction is True and r[0].item_key == "CORR2021"
|
|
assert r[0].effective_date == dt.date(2021, 1, 19)
|
|
|
|
def test_neighbouring_year_correction_keeps_january_first(self, con, store):
|
|
# 20982-style: a 2016 revalue whose only FR mention is the CY2015
|
|
# correction notice (±1 year) stays dated 2016-01-01.
|
|
write_events(
|
|
con,
|
|
"55555",
|
|
[
|
|
_rvu("55555", 2016, "revalued", "+20.0%"),
|
|
_fr("55555", 2015, "created", "6N8FPFHF", 3),
|
|
],
|
|
)
|
|
con.executemany(
|
|
"INSERT INTO pfs.rvu VALUES (?,?,?,?,?,?)",
|
|
[
|
|
("55555", None, "x", "A", 1.0, 2015),
|
|
("55555", None, "x", "A", 1.2, 2016),
|
|
],
|
|
)
|
|
rows = build(con, store)
|
|
r = _by(rows, "55555", "revalued")[0]
|
|
assert r.item_key == "6N8FPFHF" and r.correction is True
|
|
assert r.effective_date == dt.date(2016, 1, 1)
|
|
# and its 2015 becomes-payable IS dated by the correction (same year)
|
|
assert _by(rows, "55555", "becomes-payable")[0].effective_date == dt.date(
|
|
2015, 3, 20
|
|
)
|
|
|
|
def test_bands_and_family_carried(self, con, store):
|
|
rows = build(
|
|
con,
|
|
store,
|
|
family_of=lambda c: (
|
|
"CCM" if c in {"99490", "99487", "99439", "G2058"} else ""
|
|
),
|
|
)
|
|
r = _by(rows, "99487", "becomes-payable")[0]
|
|
assert r.family == "CCM" and r.rvu_band >= 1 and r.non_fac_total == 2.6
|
|
e = _by(rows, "G2058", "ends")[0]
|
|
assert e.non_fac_total == 1.05 and e.rvu_band >= 1 # from the last payable year
|
|
assert _by(rows, "12345", "ends")[0].family == ""
|
|
|
|
def test_rows_sorted_and_unique(self, con, store):
|
|
rows = build(con, store)
|
|
keys = [(r.code, r.year, r.kind) for r in rows]
|
|
assert len(keys) == len(set(keys))
|
|
assert keys == sorted(keys)
|
|
|
|
def test_derive_is_pure(self):
|
|
series = {"X1234": [(2015, "A", 1.0), (2016, "A", 1.0), (2017, "N", 0.0)]}
|
|
rows = derive(series, {}, (2015, 2017), family_of=lambda c: "", rule_info={})
|
|
assert [(r.kind, r.year) for r in rows] == [("ends", 2017)]
|
|
|
|
|
|
class TestPersist:
|
|
def test_write_and_read_roundtrip(self, con, store):
|
|
rows = build(con, store)
|
|
n = write_exposures(con, rows)
|
|
assert n == len(rows) > 0
|
|
back = read_exposures(con)
|
|
assert back == rows
|
|
assert read_exposures(con, code="99487") == _by(rows, "99487")
|
|
assert all(isinstance(r, ExposureRow) for r in back)
|
|
write_exposures(con, rows) # idempotent full replace
|
|
assert len(read_exposures(con)) == n
|
|
|
|
|
|
class TestControls:
|
|
def test_controls_match_status_and_band_outside_window(self, con, store):
|
|
rows = build(con, store)
|
|
write_exposures(con, rows)
|
|
# 99487 becomes payable 2017 (A, band by 2.6). Controls: A-status codes in the
|
|
# same band that year with no exposure in 2015..2019.
|
|
ctrl = control_codes(con, "99487", 2017, window=2)
|
|
codes = {c for c, *_ in ctrl}
|
|
assert "99487" not in codes
|
|
assert (
|
|
"99490" not in codes or True
|
|
) # 99490's only exposure is 2015 → outside window, allowed
|
|
assert all(status == "A" for _, _, status, _ in ctrl)
|
|
band = _by(rows, "99487", "becomes-payable")[0].rvu_band
|
|
assert all(b == band for *_, b in ctrl)
|
|
# 12345 has a 2019 status-change → inside the window → excluded even if same band
|
|
assert "12345" not in codes
|
|
|
|
def test_unknown_code_year_is_empty(self, con, store):
|
|
write_exposures(con, build(con, store))
|
|
assert control_codes(con, "ZZZZZ", 2017) == []
|