feat(notebooks): CY2026 PFS proposed-rule financial changes + APM (refs #597)

This commit is contained in:
kert
2026-08-13 13:52:05 -04:00
parent ab0c23290b
commit d35e50d575

View File

@@ -0,0 +1,731 @@
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 101174) 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.
""")
return
@app.cell(hide_code=True)
def _():
import altair as alt
import polars as pl
from conf import connect
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, 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 101174.
"""),
]
)
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
def _reconstruct(annual_update_pct, bn_adjustor):
return _cy25_cf * 1.0250 * (1 + annual_update_pct / 100) * bn_adjustor
_bn_rows = [
{
"vintage": "CY2026 Proposed",
"track": "Non-QP",
"one_time_pct": 2.50,
"annual_update_pct": 0.25,
"bn_adjustor": _prop.budget_neutrality_adjustor,
"reconstructed_cf": round(_reconstruct(0.25, _prop.budget_neutrality_adjustor), 4),
"registry_cf": _prop.conversion_factor,
},
{
"vintage": "CY2026 Proposed",
"track": "QP",
"one_time_pct": 2.50,
"annual_update_pct": 0.75,
"bn_adjustor": _prop.budget_neutrality_adjustor,
"reconstructed_cf": round(_reconstruct(0.75, _prop.budget_neutrality_adjustor), 4),
"registry_cf": _prop.cf_qp,
},
{
"vintage": "CY2026 Final",
"track": "Non-QP",
"one_time_pct": 2.50,
"annual_update_pct": 0.25,
"bn_adjustor": _cy26.budget_neutrality_adjustor,
"reconstructed_cf": round(_reconstruct(0.25, _cy26.budget_neutrality_adjustor), 4),
"registry_cf": _cy26.conversion_factor,
},
{
"vintage": "CY2026 Final",
"track": "QP",
"one_time_pct": 2.50,
"annual_update_pct": 0.75,
"bn_adjustor": _cy26.budget_neutrality_adjustor,
"reconstructed_cf": round(_reconstruct(0.75, _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("""
`reconstructed_cf = CY2025_CF × 1.0250 × (1 + annual_update) × bn_adjustor`
— `bn_adjustor` and `registry_cf` are read from `RULES[2026]` /
`RULES[2026].proposed`; the 2.50% one-time increase and the
0.25%/0.75% annual updates are CMS's own stated components
(90 FR 32802), not registry fields, transcribed here only for
the cross-check.
"""),
]
)
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, 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
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, nonfac_pe_rvu, mp_rvu,
work_rvu + nonfac_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 (modifier IS NULL OR modifier = '')
AND status_code = 'A'
AND nonfac_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, 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])
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.modifier IS NULL OR p.modifier='') AND p.status_code='A' AND p.nonfac_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.hcpcs=f.hcpcs AND (p.modifier IS NULL OR p.modifier=''))
""").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):
_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)
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 "Sunset")
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", "Sunset"])),
tooltip=["performance_year", "payment_year", "apm_incentive_pct", "status"],
)
.properties(title="APM Incentive Payment Sunset", width=600, height=300)
)
mo.vstack(
[
incentive_chart,
mo.md("""
Payment year 2026 (QP Performance Period 2024) is, under
current law, the **last** year with a lump-sum APM incentive
payment (1.88% of base-year paid claims). From payment year
2027 onward, QP status stops paying a bonus and starts
(instead) determining *which conversion factor applies* — see
below.
"""),
]
)
return
@app.cell(hide_code=True)
def _(QPP, mo):
_proposed = QPP[2026].proposed
mo.md(f"""
### CY2026 NPRM proposed QPP changes — proposed vs. finalized disposition
| 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** — remain 75%/50% (QP), 50%/35% (Partial QP) |
| 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`
({_proposed.federal_register_citation}, {_proposed.cms_rule_id}).
""")
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 101174). CFs —
`pfs.rules.RULES[2026]` (final, 90 FR 101174) 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, so every figure above can be traced back to a specific
ingest run, source file, and Federal Register citation.
""")
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', 'pfs.gpci')
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()