marimo's data-table viewer formats integer columns with thousands separators, so int32/int64 year columns (DuckDB SELECT year, registry- built frames) displayed as "2,027". New conf.display.plain_years casts year-like integer columns (year, *_year, *_period; autodetected or explicit, polars + pandas) to strings at the display boundary only — analysis frames keep integer dtypes, chart encodings (already :O) are untouched. Applied at every affected display site: pfs_calcs carrier/SQL result tables, pfs_reconciliation delta table, cy2026/cy2027 APM-threshold tables, cms_quality_measures pipeline-result accordions. All five notebooks re-executed headlessly in the notebooks container (nb_integration ci-smoke set): pass=5, displayed year values now serialize as strings.
809 lines
32 KiB
Python
809 lines
32 KiB
Python
import marimo
|
||
|
||
__generated_with = "0.23.1"
|
||
app = marimo.App(width="medium")
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _():
|
||
import marimo as mo
|
||
|
||
return (mo,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
# CY2026 PFS Proposed Rule — Financial Changes & Advanced APM
|
||
|
||
CMS's CY2026 Physician Fee Schedule rulemaking is unusual: the NPRM
|
||
(CMS-1832-P, 90 FR 32352, July 16, 2025) proposed the **first-ever
|
||
split conversion factor** — separate dollar amounts for clinicians who
|
||
qualify as Advanced APM Participants (QPs) versus everyone else — and
|
||
the Final Rule (90 FR 49266) changed several of those numbers again
|
||
before taking effect.
|
||
|
||
**Mechanism:** MACRA 2015 (section 1848(d)(20) of the Act) sets two
|
||
statutory annual updates starting payment year 2026 — +0.75%/yr for
|
||
the qualifying-APM (QP) conversion factor, +0.25%/yr for everyone
|
||
else — on top of a one-time 2.50% increase and the usual budget
|
||
neutrality adjustment. This notebook walks the CF from CY2025 through
|
||
the NPRM to the Final Rule, quantifies which HCPCS codes moved the
|
||
most between the proposed and final RVU tables, and lays out what
|
||
Advanced APM (QP) status is worth in dollars for CY2026.
|
||
|
||
**What this means:** every number below is read live from
|
||
`pfs.rules.RULES` / `qpp.QPP` (the repo's rule registries) and the
|
||
`pfs`/`cms` DuckLake schemas — nothing here is a hard-coded figure.
|
||
All dollar figures use **national, GPCI-unadjusted RVUs (GPCI = 1.0)**
|
||
— a deliberate scope simplification, disclosed once here rather than
|
||
on every figure; real payment varies by locality via `pfs.gpci`,
|
||
which this notebook does not join.
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _():
|
||
import altair as alt
|
||
import polars as pl
|
||
|
||
from conf import connect
|
||
from conf.display import plain_years
|
||
from pfs.rules import RULES
|
||
from qpp import QPP, for_payment_year
|
||
|
||
connect.theme()
|
||
|
||
# connect.theme() puts assets/ on sys.path — reuse the HTI-5 design
|
||
# tokens directly instead of re-typing hex literals for the gain/loss
|
||
# diverging pair used in Sections 2-3.
|
||
from fhirworx import AMBER, TEAL
|
||
|
||
# PFS reference data lives in the DuckLake lakehouse (M5, #514) —
|
||
# notebooks connect read-only.
|
||
con = connect.ducklake()
|
||
|
||
def q(sql):
|
||
return con.execute(sql).pl()
|
||
|
||
return AMBER, QPP, RULES, TEAL, alt, con, for_payment_year, pl, plain_years, q
|
||
|
||
|
||
# ── 1. The conversion-factor walk ─────────────────────────────────────
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
## 1. The Conversion Factor Walk — CY2025 → NPRM → Final Rule
|
||
|
||
CY2026 is the first year the PFS publishes **four** conversion
|
||
factors at once: a standard and an anesthesia CF, each split into a
|
||
qualifying-APM (QP) and nonqualifying-APM (non-QP) track. The chart
|
||
below places every published value — CY2025's single CF, the NPRM's
|
||
proposed values, and the Final Rule's finalized values — on one
|
||
timeline.
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(RULES, pl):
|
||
_cy25 = RULES[2025]
|
||
_cy26 = RULES[2026]
|
||
_prop = _cy26.proposed
|
||
|
||
_rows = [
|
||
{"vintage": "CY2025 Final", "family": "Standard", "track": "Non-QP", "cf": _cy25.conversion_factor},
|
||
{"vintage": "CY2026 Proposed", "family": "Standard", "track": "Non-QP", "cf": _prop.conversion_factor},
|
||
{"vintage": "CY2026 Final", "family": "Standard", "track": "Non-QP", "cf": _cy26.conversion_factor},
|
||
{"vintage": "CY2026 Proposed", "family": "Standard", "track": "QP", "cf": _prop.cf_qp},
|
||
{"vintage": "CY2026 Final", "family": "Standard", "track": "QP", "cf": _cy26.cf_qp},
|
||
{"vintage": "CY2026 Proposed", "family": "Anesthesia", "track": "Non-QP", "cf": _prop.anesthesia_cf},
|
||
{"vintage": "CY2026 Final", "family": "Anesthesia", "track": "Non-QP", "cf": _cy26.anesthesia_cf},
|
||
{"vintage": "CY2026 Proposed", "family": "Anesthesia", "track": "QP", "cf": _prop.anesthesia_cf_qp},
|
||
# NOTE: RULES[2026].anesthesia_cf models the non-QP anesthesia CF
|
||
# only — a final QP anesthesia CF is not yet captured in the
|
||
# registry (see pfs.rules module docstring), so that bar is
|
||
# deliberately omitted rather than guessed.
|
||
]
|
||
cf_walk = pl.DataFrame(_rows)
|
||
cf_walk
|
||
return (cf_walk,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(alt, cf_walk, mo):
|
||
_vintage_order = ["CY2025 Final", "CY2026 Proposed", "CY2026 Final"]
|
||
_track_domain = ["Non-QP", "QP"]
|
||
|
||
cf_chart = (
|
||
alt.Chart(cf_walk.to_pandas())
|
||
.mark_bar()
|
||
.encode(
|
||
x=alt.X("vintage:N", title=None, sort=_vintage_order),
|
||
xOffset=alt.XOffset("track:N", sort=_track_domain),
|
||
y=alt.Y("cf:Q", title="Conversion factor ($/RVU)", scale=alt.Scale(zero=False)),
|
||
color=alt.Color("track:N", title="Track", scale=alt.Scale(domain=_track_domain)),
|
||
tooltip=["vintage", "family", "track", alt.Tooltip("cf:Q", format="$.4f")],
|
||
)
|
||
.properties(width=260, height=280)
|
||
.facet(column=alt.Column("family:N", title=None))
|
||
.resolve_scale(y="independent")
|
||
.properties(title="CY2026 PFS Conversion Factors — Proposed vs. Final")
|
||
)
|
||
|
||
mo.vstack(
|
||
[
|
||
cf_chart,
|
||
mo.md("""
|
||
*Anesthesia QP (final) is not shown — the Final Rule did not
|
||
restate a separate QP anesthesia CF in a form captured by the
|
||
`pfs.rules` registry yet.*
|
||
|
||
**Sources:** CY2025 — 89 FR 98452. CY2026 NPRM — 90 FR 32352
|
||
(CMS-1832-P). CY2026 Final Rule — 90 FR 49266.
|
||
"""),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
### Budget-neutrality adjustor decomposition
|
||
|
||
CMS's own NPRM narrative (90 FR 32802) attributes the CY2026 CF
|
||
change to three multiplicative pieces: a one-time +2.50% statutory
|
||
increase, the +0.25%/+0.75% nonqualifying/qualifying-APM annual
|
||
updates, and the budget-neutrality (BN) adjustor. Compounding those
|
||
stated percentages against the CY2025 CF reproduces the registry's
|
||
actual proposed/final values to within rounding — a useful
|
||
cross-check that the registry's transcription is internally
|
||
consistent with CMS's narrative.
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(RULES, mo, pl):
|
||
_cy25_cf = RULES[2025].conversion_factor
|
||
_cy26 = RULES[2026]
|
||
_prop = _cy26.proposed
|
||
|
||
# CMS's own stated CF-update components (90 FR 32802) — a one-time
|
||
# statutory increase plus the MACRA 2015 sec. 1848(d)(20) annual
|
||
# updates. NONE of these three percentages is a field anywhere in
|
||
# `pfs.rules` or `qpp` — there is nothing to attribute-access. They
|
||
# are kept as literals ONLY because CMS's narrative states them as
|
||
# bare numbers, not derived from any other registry value; this is a
|
||
# cited transcription of that narrative, not a second source of
|
||
# truth for `budget_neutrality_adjustor` or the CFs themselves
|
||
# (both of which — `bn_adjustor` / `registry_cf` below — ARE read
|
||
# live from the registry).
|
||
_ONE_TIME_INCREASE_MULT = 1.0250 # +2.50% single-year statutory increase, 90 FR 32802
|
||
_ANNUAL_UPDATE_MULT = {
|
||
"Non-QP": 1.0025, # +0.25%/yr nonqualifying-APM update, 90 FR 32802
|
||
"QP": 1.0075, # +0.75%/yr qualifying-APM update, 90 FR 32802
|
||
}
|
||
|
||
def _reconstruct(track, bn_adjustor):
|
||
return _cy25_cf * _ONE_TIME_INCREASE_MULT * _ANNUAL_UPDATE_MULT[track] * bn_adjustor
|
||
|
||
_bn_rows = [
|
||
{
|
||
"vintage": "CY2026 Proposed",
|
||
"track": "Non-QP",
|
||
"one_time_mult": _ONE_TIME_INCREASE_MULT,
|
||
"annual_update_mult": _ANNUAL_UPDATE_MULT["Non-QP"],
|
||
"bn_adjustor": _prop.budget_neutrality_adjustor,
|
||
"reconstructed_cf": round(_reconstruct("Non-QP", _prop.budget_neutrality_adjustor), 4),
|
||
"registry_cf": _prop.conversion_factor,
|
||
},
|
||
{
|
||
"vintage": "CY2026 Proposed",
|
||
"track": "QP",
|
||
"one_time_mult": _ONE_TIME_INCREASE_MULT,
|
||
"annual_update_mult": _ANNUAL_UPDATE_MULT["QP"],
|
||
"bn_adjustor": _prop.budget_neutrality_adjustor,
|
||
"reconstructed_cf": round(_reconstruct("QP", _prop.budget_neutrality_adjustor), 4),
|
||
"registry_cf": _prop.cf_qp,
|
||
},
|
||
{
|
||
"vintage": "CY2026 Final",
|
||
"track": "Non-QP",
|
||
"one_time_mult": _ONE_TIME_INCREASE_MULT,
|
||
"annual_update_mult": _ANNUAL_UPDATE_MULT["Non-QP"],
|
||
"bn_adjustor": _cy26.budget_neutrality_adjustor,
|
||
"reconstructed_cf": round(_reconstruct("Non-QP", _cy26.budget_neutrality_adjustor), 4),
|
||
"registry_cf": _cy26.conversion_factor,
|
||
},
|
||
{
|
||
"vintage": "CY2026 Final",
|
||
"track": "QP",
|
||
"one_time_mult": _ONE_TIME_INCREASE_MULT,
|
||
"annual_update_mult": _ANNUAL_UPDATE_MULT["QP"],
|
||
"bn_adjustor": _cy26.budget_neutrality_adjustor,
|
||
"reconstructed_cf": round(_reconstruct("QP", _cy26.budget_neutrality_adjustor), 4),
|
||
"registry_cf": _cy26.cf_qp,
|
||
},
|
||
]
|
||
bn_decomp = pl.DataFrame(_bn_rows).with_columns(
|
||
(pl.col("reconstructed_cf") - pl.col("registry_cf")).abs().round(4).alias("abs_diff")
|
||
)
|
||
|
||
mo.vstack(
|
||
[
|
||
bn_decomp,
|
||
mo.md(f"""
|
||
`reconstructed_cf = CY2025_CF × one_time_mult × annual_update_mult × bn_adjustor`
|
||
— `bn_adjustor` and `registry_cf` are read live from
|
||
`RULES[2026]` / `RULES[2026].proposed`. `one_time_mult`
|
||
({_ONE_TIME_INCREASE_MULT}) and `annual_update_mult`
|
||
({_ANNUAL_UPDATE_MULT["Non-QP"]} non-QP /
|
||
{_ANNUAL_UPDATE_MULT["QP"]} QP) are **cited transcriptions of
|
||
CMS's NPRM narrative (90 FR 32802)**, not registry fields —
|
||
no field in `pfs.rules` or `qpp` models the 2.50%/0.25%/0.75%
|
||
statutory update components individually, only their combined
|
||
effect on `conversion_factor` / `cf_qp`.
|
||
"""),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
# ── 2. RVU-level deltas ────────────────────────────────────────────────
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
## 2. RVU-Level Deltas — Which HCPCS Codes Move the Most
|
||
|
||
`pfs.rvu_proposed` (14,169 rows as of this ingest, CMS-1832-P) is
|
||
compared against `pfs.rvu` for CY2025 (pre-rule baseline) and CY2026
|
||
(what actually got finalized). Both `pfs.rvu` and `pfs.rvu_proposed`
|
||
are deduplicated to one row per HCPCS base code (no modifier),
|
||
`status_code = 'A'` (actively priced), and — for the proposed table
|
||
specifically — a non-null non-facility PE RVU, since ~29% of
|
||
`pfs.rvu_proposed` rows (as of this ingest) carry a null
|
||
non-facility *or* facility PE RVU (CMS's Addendum B only populates
|
||
the setting a code is actually priced in).
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(q):
|
||
rvu_delta_raw = q("""
|
||
WITH proposed AS (
|
||
SELECT hcpcs, description, work_rvu, non_fac_pe_rvu, mp_rvu,
|
||
work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu,
|
||
CASE
|
||
WHEN hcpcs BETWEEN '99201' AND '99499' THEN 'E/M'
|
||
WHEN hcpcs BETWEEN '10000' AND '19999' THEN 'Integumentary'
|
||
WHEN hcpcs BETWEEN '20000' AND '29999' THEN 'Musculoskeletal'
|
||
WHEN hcpcs BETWEEN '30000' AND '39999' THEN 'Resp/Cardiovascular'
|
||
WHEN hcpcs BETWEEN '40000' AND '49999' THEN 'Digestive'
|
||
WHEN hcpcs BETWEEN '50000' AND '59999' THEN 'Urinary/Genital'
|
||
WHEN hcpcs BETWEEN '60000' AND '69999' THEN 'Endocrine/Nervous'
|
||
WHEN hcpcs BETWEEN '70000' AND '79999' THEN 'Radiology'
|
||
WHEN hcpcs BETWEEN '80000' AND '89999' THEN 'Path/Lab'
|
||
WHEN hcpcs BETWEEN '90000' AND '99199' THEN 'Medicine'
|
||
WHEN hcpcs LIKE 'G%' THEN 'G-codes'
|
||
ELSE 'Other'
|
||
END AS category
|
||
FROM pfs.rvu_proposed
|
||
WHERE cms_rule_id = 'CMS-1832-P'
|
||
AND (mod IS NULL OR mod = '')
|
||
AND status_code = 'A'
|
||
AND non_fac_pe_rvu IS NOT NULL
|
||
QUALIFY row_number() OVER (PARTITION BY hcpcs ORDER BY hcpcs) = 1
|
||
),
|
||
final_2025 AS (
|
||
SELECT hcpcs, work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu
|
||
FROM pfs.rvu
|
||
WHERE year = 2025 AND (mod IS NULL OR mod = '') AND status_code = 'A'
|
||
QUALIFY row_number() OVER (PARTITION BY hcpcs ORDER BY hcpcs) = 1
|
||
),
|
||
final_2026 AS (
|
||
SELECT hcpcs, work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu
|
||
FROM pfs.rvu
|
||
WHERE year = 2026 AND (mod IS NULL OR mod = '') AND status_code = 'A'
|
||
QUALIFY row_number() OVER (PARTITION BY hcpcs ORDER BY hcpcs) = 1
|
||
)
|
||
SELECT
|
||
p.hcpcs,
|
||
p.description,
|
||
p.category,
|
||
p.total_nf_rvu AS total_nf_rvu_proposed,
|
||
f25.total_nf_rvu AS total_nf_rvu_2025,
|
||
f26.total_nf_rvu AS total_nf_rvu_2026final
|
||
FROM proposed p
|
||
JOIN final_2025 f25 USING (hcpcs)
|
||
JOIN final_2026 f26 USING (hcpcs)
|
||
""")
|
||
rvu_delta_raw
|
||
return (rvu_delta_raw,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(RULES, pl, rvu_delta_raw):
|
||
_cf_2025 = RULES[2025].conversion_factor
|
||
_cf_2026_proposed = RULES[2026].proposed.conversion_factor
|
||
_cf_2026_final = RULES[2026].conversion_factor
|
||
|
||
rvu_delta = (
|
||
rvu_delta_raw.with_columns(
|
||
(pl.col("total_nf_rvu_proposed") * _cf_2026_proposed).round(2).alias("dollar_proposed"),
|
||
(pl.col("total_nf_rvu_2025") * _cf_2025).round(2).alias("dollar_2025"),
|
||
(pl.col("total_nf_rvu_2026final") * _cf_2026_final).round(2).alias("dollar_2026final"),
|
||
)
|
||
.with_columns(
|
||
(pl.col("dollar_proposed") - pl.col("dollar_2025")).round(2).alias("delta_vs_2025"),
|
||
(pl.col("dollar_proposed") - pl.col("dollar_2026final")).round(2).alias("delta_vs_final"),
|
||
)
|
||
)
|
||
return (rvu_delta,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
baseline_picker = mo.ui.radio(
|
||
options={
|
||
"NPRM impact — proposed vs. CY2025 final (what the rule proposed to change)": "delta_vs_2025",
|
||
"Proposed→finalized drift — proposed vs. CY2026 final (what the comment period changed)": "delta_vs_final",
|
||
},
|
||
value="NPRM impact — proposed vs. CY2025 final (what the rule proposed to change)",
|
||
label="Compare",
|
||
)
|
||
baseline_picker
|
||
return (baseline_picker,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(AMBER, RULES, TEAL, alt, baseline_picker, mo, rvu_delta):
|
||
_col = baseline_picker.value
|
||
_n = 20
|
||
|
||
_winners = rvu_delta.sort(_col, descending=True).head(_n)
|
||
_losers = rvu_delta.sort(_col, descending=False).head(_n)
|
||
_top40 = _winners.vstack(_losers)
|
||
|
||
_plot_df = _top40.to_pandas()
|
||
_plot_df["label"] = _plot_df["hcpcs"] + " — " + _plot_df["description"].str.slice(0, 40)
|
||
_plot_df["sign"] = _plot_df[_col].apply(lambda v: "Gain" if v >= 0 else "Loss")
|
||
|
||
winners_losers_chart = (
|
||
alt.Chart(_plot_df)
|
||
.mark_bar()
|
||
.encode(
|
||
x=alt.X(f"{_col}:Q", title="$ change (national unadjusted, GPCI=1.0)"),
|
||
y=alt.Y("label:N", title=None, sort=alt.SortField(field=_col, order="descending")),
|
||
color=alt.Color(
|
||
"sign:N",
|
||
title=None,
|
||
scale=alt.Scale(domain=["Gain", "Loss"], range=[TEAL, AMBER]),
|
||
legend=alt.Legend(orient="top"),
|
||
),
|
||
tooltip=["hcpcs", "description", alt.Tooltip(f"{_col}:Q", format="$.2f")],
|
||
)
|
||
.properties(
|
||
title=f"Top 20 Winners / Top 20 Losers — {baseline_picker.value}",
|
||
width=700,
|
||
height=600,
|
||
)
|
||
)
|
||
|
||
mo.vstack(
|
||
[
|
||
winners_losers_chart,
|
||
mo.md(f"""
|
||
**Sources:** `pfs.rvu_proposed` — CMS-1832-P NPRM,
|
||
{RULES[2026].proposed.federal_register_citation}. Baseline —
|
||
`pfs.rvu` CY2025 Final ({RULES[2025].federal_register_citation})
|
||
or CY2026 Final ({RULES[2026].federal_register_citation}), per
|
||
the comparison selected above.
|
||
"""),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo, rvu_delta):
|
||
mo.vstack(
|
||
[
|
||
mo.md(f"""
|
||
**Full detail — {rvu_delta.height:,} codes** matched across
|
||
proposed, CY2025-final, and CY2026-final RVU tables (search
|
||
the table below by HCPCS or description).
|
||
"""),
|
||
mo.ui.table(
|
||
rvu_delta.sort("delta_vs_2025").to_pandas(),
|
||
page_size=25,
|
||
label="RVU / Payment Deltas by HCPCS",
|
||
),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(con, mo):
|
||
_new = con.execute("""
|
||
SELECT count(*) FROM pfs.rvu_proposed p
|
||
WHERE p.cms_rule_id = 'CMS-1832-P' AND (p.mod IS NULL OR p.mod='') AND p.status_code='A' AND p.non_fac_pe_rvu IS NOT NULL
|
||
AND NOT EXISTS (SELECT 1 FROM pfs.rvu f WHERE f.year=2025 AND f.hcpcs=p.hcpcs AND (f.mod IS NULL OR f.mod=''))
|
||
""").fetchone()[0]
|
||
_dropped = con.execute("""
|
||
SELECT count(*) FROM pfs.rvu f
|
||
WHERE f.year=2025 AND (f.mod IS NULL OR f.mod='') AND f.status_code='A'
|
||
AND NOT EXISTS (SELECT 1 FROM pfs.rvu_proposed p WHERE p.cms_rule_id = 'CMS-1832-P' AND p.hcpcs=f.hcpcs AND (p.mod IS NULL OR p.mod=''))
|
||
""").fetchone()[0]
|
||
mo.md(f"""
|
||
> **Coverage caveat:** {_new} HCPCS codes appear in `pfs.rvu_proposed`
|
||
> with no CY2025-final counterpart (new/renumbered codes), and {_dropped}
|
||
> CY2025-final codes have no match in `pfs.rvu_proposed` (dropped,
|
||
> bundled, or excluded from the Addendum B extract used to build the
|
||
> lake table). Both groups are excluded from the delta analysis above
|
||
> rather than shown with a fabricated baseline.
|
||
|
||
**Source:** `pfs.rvu_proposed` — CMS-1832-P Addendum B, 90 FR 32352.
|
||
`pfs.rvu` — annual PFS Final Rule Addendum B.
|
||
""")
|
||
return
|
||
|
||
|
||
# ── 3. Specialty impact ────────────────────────────────────────────────
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
## 3. Specialty Impact
|
||
|
||
CMS's own specialty-impact table — **Table 92, "CY 2026 PFS Estimated
|
||
Impact on Total Allowed Charges by Specialty"** (NPRM, 90 FR
|
||
32802–32806) — is what the brief calls for here. It is **not
|
||
available as transcribable text**: the Federal Register's own
|
||
full-text extraction of the NPRM embeds Table 92 as five scanned TIFF
|
||
graphics (`EP16JY25.178`–`.182`), not selectable text, in both the
|
||
plain-text and PDF-text-layer versions of `data/fr_downloads/
|
||
2025-13271.{txt,pdf}` that are ingested into this repository. There is
|
||
also no specialty-to-HCPCS mapping in the lake (`information_schema`
|
||
shows only `cms`, `opps`, and `pfs` schemas — no `reference_data`
|
||
schema exists to join against). CMS's real specialty attribution uses
|
||
claims-weighted utilization by specialty, which this repo does not
|
||
ingest.
|
||
|
||
**What follows instead** is a coarse CPT-range proxy — the same
|
||
category buckets used in `skin_sub_budget_neutrality.py` — applied to
|
||
the RVU deltas computed in Section 2. It approximates *which kinds of
|
||
services* moved, not *which specialties* were affected, and is
|
||
explicitly **not** a substitute for Table 92.
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(pl, rvu_delta):
|
||
category_impact = (
|
||
rvu_delta.group_by("category")
|
||
.agg(
|
||
pl.col("hcpcs").count().alias("codes"),
|
||
pl.col("delta_vs_2025").sum().round(0).alias("total_delta_vs_2025"),
|
||
pl.col("delta_vs_final").sum().round(0).alias("total_delta_vs_final"),
|
||
)
|
||
.sort("total_delta_vs_2025", descending=True)
|
||
)
|
||
category_impact
|
||
return (category_impact,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(AMBER, TEAL, alt, category_impact, mo):
|
||
_df = category_impact.to_pandas()
|
||
_df["sign"] = _df["total_delta_vs_2025"].apply(lambda v: "Net gain" if v >= 0 else "Net loss")
|
||
|
||
category_chart = (
|
||
alt.Chart(_df)
|
||
.mark_bar()
|
||
.encode(
|
||
x=alt.X("total_delta_vs_2025:Q", title="Aggregate $ change vs. CY2025 (proxy category)"),
|
||
y=alt.Y("category:N", title=None, sort="-x"),
|
||
color=alt.Color(
|
||
"sign:N",
|
||
title=None,
|
||
scale=alt.Scale(domain=["Net gain", "Net loss"], range=[TEAL, AMBER]),
|
||
legend=alt.Legend(orient="top"),
|
||
),
|
||
tooltip=["category", "codes", alt.Tooltip("total_delta_vs_2025:Q", format="$,.0f")],
|
||
)
|
||
.properties(title="CPT-Range Proxy — Not CMS's Official Specialty Table", width=700, height=350)
|
||
)
|
||
|
||
mo.vstack(
|
||
[
|
||
category_chart,
|
||
mo.md("""
|
||
*Proxy category buckets, not specialty attribution. CMS's
|
||
official specialty impacts: Table 92, NPRM 90 FR 32802–32806
|
||
(embedded graphic, not machine-readable in this repo's FR
|
||
corpus). Public-use file with granular specialty impacts:
|
||
cms.gov, CY2026 PFS proposed rule downloads.*
|
||
"""),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
# ── 4. Advanced APM requirements ───────────────────────────────────────
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
## 4. Advanced APM (QP) Requirements
|
||
|
||
Under MACRA 2015, clinicians who participate heavily enough in an
|
||
Advanced Alternative Payment Model (Advanced APM) during a "QP
|
||
Performance Period" become Qualifying APM Participants (QPs) for the
|
||
payment year two years later (`payment_year = performance_year + 2`).
|
||
QP status has, historically, meant MIPS exclusion and a lump-sum
|
||
incentive payment; starting payment year 2026 it also means a
|
||
**different conversion factor**.
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(for_payment_year, mo):
|
||
_governs_2026 = for_payment_year(2026)
|
||
mo.md(f"""
|
||
`qpp.for_payment_year(2026)` resolves to QP Performance Period
|
||
**{_governs_2026.performance_year}** ({_governs_2026.citation}) — the
|
||
performance period whose determinations govern CY2026 payment, and
|
||
the first performance period where `qp_cf_applies` is
|
||
**{_governs_2026.qp_cf_applies}**.
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(QPP, pl, plain_years):
|
||
_rows = []
|
||
for _perf_year, _qy in sorted(QPP.items()):
|
||
_prop = _qy.proposed
|
||
_rows.append(
|
||
{
|
||
"performance_year": _perf_year,
|
||
"payment_year": _qy.payment_year,
|
||
"qp_payment_pct": _qy.qp_thresholds.payment_amount_pct,
|
||
"qp_patient_pct": _qy.qp_thresholds.patient_count_pct,
|
||
"partial_qp_payment_pct": _qy.partial_qp_thresholds.payment_amount_pct,
|
||
"partial_qp_patient_pct": _qy.partial_qp_thresholds.patient_count_pct,
|
||
"revenue_nominal_pct": _qy.risk_standards.revenue_nominal_pct,
|
||
"benchmark_nominal_pct": _qy.risk_standards.benchmark_nominal_pct,
|
||
"apm_incentive_pct": _qy.apm_incentive_pct,
|
||
"qp_cf_applies": _qy.qp_cf_applies,
|
||
"cehrt_required": _qy.cehrt_required,
|
||
"proposed_numeric_change": (
|
||
"none — see disposition table below" if _prop is not None else "n/a"
|
||
),
|
||
"citation": _qy.citation,
|
||
}
|
||
)
|
||
apm_thresholds = pl.DataFrame(_rows)
|
||
plain_years(apm_thresholds)
|
||
return (apm_thresholds,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(alt, apm_thresholds, mo):
|
||
_df = apm_thresholds.to_pandas()
|
||
_df["apm_incentive_pct"] = _df["apm_incentive_pct"].fillna(0.0)
|
||
_df["status"] = _df["apm_incentive_pct"].apply(lambda v: "Paid" if v > 0 else "None under current law")
|
||
|
||
incentive_chart = (
|
||
alt.Chart(_df)
|
||
.mark_bar()
|
||
.encode(
|
||
x=alt.X("payment_year:O", title="Payment year"),
|
||
y=alt.Y("apm_incentive_pct:Q", title="APM incentive payment (% of base-year Part B paid claims)"),
|
||
color=alt.Color(
|
||
"status:N",
|
||
title=None,
|
||
scale=alt.Scale(domain=["Paid", "None under current law"]),
|
||
),
|
||
tooltip=["performance_year", "payment_year", "apm_incentive_pct", "status"],
|
||
)
|
||
.properties(title="APM Incentive Payment — Not a Clean Sunset", width=600, height=300)
|
||
)
|
||
|
||
_citations = ", ".join(sorted(set(_df["citation"])))
|
||
|
||
mo.vstack(
|
||
[
|
||
incentive_chart,
|
||
mo.md(f"""
|
||
The lump-sum APM Incentive Payment does **not** sunset
|
||
cleanly after payment year 2026. Payment year 2027 (QP
|
||
Performance Period 2025) has no incentive under current
|
||
law. The Consolidated Appropriations Act, 2026 (CAA 2026,
|
||
Pub. L. 119-75) then **revives a 3.1% incentive for payment
|
||
year 2028 only** (`QPP[2026]`) — CMS-1848-P proposes to
|
||
codify that revival into 42 CFR 414.1450(b)(1) (91 FR
|
||
44218-44219, 44286), superseding the CY2026 Final Rule's
|
||
then-accurate "no incentive after 2026" narrative reflected
|
||
in the disposition table below. So the pattern across
|
||
payment years 2025-2028 is paid / paid / gap / revived, not
|
||
a single cutoff.
|
||
|
||
**Sources:** `qpp.QPP` performance years 2023–2026
|
||
({_citations}).
|
||
"""),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(QPP, mo):
|
||
_cy = QPP[2026]
|
||
_proposed = _cy.proposed
|
||
|
||
# As of the CY2026 PFS Final Rule (90 FR 49980), the payment-year-2028
|
||
# QP/Partial QP thresholds were understood to be 75%/50% (QP) and
|
||
# 50%/35% (Partial QP) -- CMS-1832-P proposed, and the Final Rule
|
||
# finalized, "no numeric change" from those then-current-law figures.
|
||
# These two literals are a CITED TRANSCRIPTION of that now-superseded
|
||
# disposition, not a live read of `QPP[2026].qp_thresholds` /
|
||
# `partial_qp_thresholds` -- those fields were corrected under task
|
||
# #620 (CAA 2026 legislatively restored 50%/35% QP, 40%/25% Partial
|
||
# QP for payment year 2028; see the `qpp` module docstring "CAA 2026
|
||
# supersession"). Pinning the historical values here rather than
|
||
# reading the registry live preserves what CMS actually said in this
|
||
# disposition table instead of retroactively rewriting it.
|
||
_qp_then = "75%/50%"
|
||
_partial_then = "50%/35%"
|
||
mo.md(f"""
|
||
### CY2026 NPRM proposed QPP changes — proposed vs. finalized disposition
|
||
|
||
Every FR page-pincite in the "Disposition" column below is quoted
|
||
**verbatim from `qpp.QPP[2026].proposed.changes`**
|
||
({_proposed.federal_register_citation}, {_proposed.cms_rule_id}) — that
|
||
field is the single source of truth for this table; nothing here is a
|
||
second, independently-typed citation.
|
||
|
||
| Proposal | Disposition |
|
||
|---|---|
|
||
| Individual-level QP determination (new Threshold Score calc, Sec. 414.1425(b)(3)) | **Finalized as proposed** (90 FR 49923–49928, 49980) |
|
||
| Attribution-eligible beneficiary definition — expand 6th criterion beyond E/M services | **Finalized WITH MODIFICATION** — dual E/M + covered-professional-services methodology (90 FR 49980) |
|
||
| Sunset the Medical Home Model 50-clinician limit (Sec. 414.1415(c)(7)) after the 2025 QP Performance Period | **Finalized as proposed** (90 FR 49929) |
|
||
| QP / Partial QP percentage thresholds | **No numeric change proposed** — remained `qp_thresholds` {_qp_then} (QP), `partial_qp_thresholds` {_partial_then} (Partial QP) as understood at CY2026 Final Rule publication (90 FR 49980) — **CITED TRANSCRIPTION, since SUPERSEDED for payment year 2028 by CAA 2026; see `QPP[2026].qp_thresholds`/`partial_qp_thresholds` for current law** |
|
||
| Nominal-amount risk standards (42 CFR 414.1415(c)(3)(i)) / CEHRT requirement | **No change proposed** — cross-referenced by section only |
|
||
|
||
Full sourced narrative: `qpp.QPP[2026].proposed.changes`.
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
### What QP status is worth in dollars — 3 example HCPCS
|
||
|
||
National, unadjusted (GPCI = 1.0) payment for a single unit of
|
||
service, computed as `(work_rvu + non_fac_pe_rvu + mp_rvu) × CF`,
|
||
comparing the QP and non-QP conversion factors for both the NPRM and
|
||
the Final Rule:
|
||
|
||
- **99213** — established-patient office visit, low complexity (E/M)
|
||
- **27447** — total knee arthroplasty (major procedure)
|
||
- **70553** — MRI brain, without and with contrast (imaging)
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(RULES, pl, q):
|
||
_example_rvu = q("""
|
||
SELECT hcpcs, description, work_rvu, non_fac_pe_rvu, mp_rvu,
|
||
work_rvu + non_fac_pe_rvu + mp_rvu AS total_rvu
|
||
FROM pfs.rvu
|
||
WHERE year = 2026 AND (mod IS NULL OR mod = '')
|
||
AND hcpcs IN ('99213', '27447', '70553')
|
||
""")
|
||
|
||
_cy26 = RULES[2026]
|
||
_prop = _cy26.proposed
|
||
_rows = []
|
||
for _r in _example_rvu.iter_rows(named=True):
|
||
for _vintage, _cf_nonqp, _cf_qp in (
|
||
("CY2026 Proposed", _prop.conversion_factor, _prop.cf_qp),
|
||
("CY2026 Final", _cy26.conversion_factor, _cy26.cf_qp),
|
||
):
|
||
_pay_nonqp = round(_r["total_rvu"] * _cf_nonqp, 2)
|
||
_pay_qp = round(_r["total_rvu"] * _cf_qp, 2)
|
||
_rows.append(
|
||
{
|
||
"hcpcs": _r["hcpcs"],
|
||
"description": _r["description"],
|
||
"vintage": _vintage,
|
||
"non_qp_payment": _pay_nonqp,
|
||
"qp_payment": _pay_qp,
|
||
"qp_differential": round(_pay_qp - _pay_nonqp, 2),
|
||
}
|
||
)
|
||
|
||
qp_differential = pl.DataFrame(_rows)
|
||
qp_differential
|
||
return (qp_differential,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(alt, mo, qp_differential):
|
||
differential_chart = (
|
||
alt.Chart(qp_differential.to_pandas())
|
||
.mark_bar()
|
||
.encode(
|
||
x=alt.X("hcpcs:N", title="HCPCS"),
|
||
xOffset=alt.XOffset("vintage:N", sort=["CY2026 Proposed", "CY2026 Final"]),
|
||
y=alt.Y("qp_differential:Q", title="QP minus non-QP payment ($, national unadjusted)"),
|
||
color=alt.Color("vintage:N", title=None, sort=["CY2026 Proposed", "CY2026 Final"]),
|
||
tooltip=["hcpcs", "description", "vintage", alt.Tooltip("qp_differential:Q", format="$.2f")],
|
||
)
|
||
.properties(title="Dollar Value of QP Status — 3 Example Codes", width=500, height=320)
|
||
)
|
||
|
||
mo.vstack(
|
||
[
|
||
differential_chart,
|
||
mo.md("""
|
||
**Sources:** RVUs — `pfs.rvu`, CY2026 (90 FR 49266). CFs —
|
||
`pfs.rules.RULES[2026]` (final, 90 FR 49266) and
|
||
`RULES[2026].proposed` (NPRM, 90 FR 32352). National unadjusted
|
||
means GPCI = 1.0 — actual payment varies by locality.
|
||
"""),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
# ── 5. Provenance ───────────────────────────────────────────────────────
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
## 5. Provenance
|
||
|
||
Ingest-log entries (`cms.ingest_log`) for every table this notebook
|
||
reads from — `pfs.rvu` and `pfs.rvu_proposed` — so every figure above
|
||
can be traced back to a specific ingest run, source file, and Federal
|
||
Register citation. (`pfs.gpci` is not queried anywhere in this
|
||
notebook — see the intro's GPCI = 1.0 scope note — so it is
|
||
deliberately excluded here rather than claimed as a source.)
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo, q):
|
||
provenance = q("""
|
||
SELECT run_id, ingested_at, module, table_name, rule_id,
|
||
source_file, sha256, rows, fr_citation, pincite_key
|
||
FROM cms.ingest_log
|
||
WHERE table_name IN ('pfs.rvu', 'pfs.rvu_proposed')
|
||
ORDER BY ingested_at DESC
|
||
""")
|
||
mo.ui.table(provenance.to_pandas(), label="Ingest Log — Tables Used in This Notebook")
|
||
return
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app.run()
|