780 lines
30 KiB
Python
780 lines
30 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("""
|
||
# CY2027 PFS Proposed Rule — Financial Changes & Advanced APM
|
||
|
||
CMS's CY2027 Physician Fee Schedule NPRM (CMS-1848-P, 91 FR 43842,
|
||
published July 16, 2026; docket CMS-2026-2377) is a **proposed rule
|
||
only** — there is no CY2027 Final Rule (CMS-1848-F) yet, and every
|
||
"CY2027" figure below is a proposal, not a finalized payment
|
||
parameter. The comment period, thresholds, and Advanced-APM
|
||
schedule captured here can all still change before the Final Rule
|
||
ships.
|
||
|
||
**What this means:** every number below is read live from
|
||
`pfs.rules.RULES` / `pfs.rules.PROPOSED` (via `proposed_for(2027)`)
|
||
and `qpp.QPP` (the repo's rule registries) and the `pfs`/`cms`
|
||
DuckLake schemas — nothing here is a hard-coded figure. Columns are
|
||
explicitly labeled **"final rule pending"** rather than fabricating
|
||
a CY2027-final column that does not exist yet. 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 pfs.rules import RULES, proposed_for
|
||
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 Section 2.
|
||
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, proposed_for, q
|
||
|
||
|
||
# ── 1. The conversion-factor walk ─────────────────────────────────────
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
## 1. The Conversion Factor Walk — CY2026 Final → CY2027 Proposed
|
||
|
||
CY2026 was the first year the PFS published a split conversion
|
||
factor — a qualifying-APM (QP) track and a nonqualifying-APM
|
||
(non-QP) track, each with a standard and an anesthesia CF. The
|
||
CY2027 NPRM proposes new values for all four; the chart below
|
||
places the CY2026 **Final Rule** values (the only finalized
|
||
baseline that exists) alongside the CY2027 **NPRM proposal**
|
||
(final rule pending).
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(RULES, pl, proposed_for):
|
||
_cy26 = RULES[2026]
|
||
_prop = proposed_for(2027)
|
||
|
||
_rows = [
|
||
{"vintage": "CY2026 Final", "family": "Standard", "track": "Non-QP", "cf": _cy26.conversion_factor},
|
||
{"vintage": "CY2027 Proposed (final rule pending)", "family": "Standard", "track": "Non-QP", "cf": _prop.conversion_factor},
|
||
{"vintage": "CY2026 Final", "family": "Standard", "track": "QP", "cf": _cy26.cf_qp},
|
||
{"vintage": "CY2027 Proposed (final rule pending)", "family": "Standard", "track": "QP", "cf": _prop.cf_qp},
|
||
{"vintage": "CY2026 Final", "family": "Anesthesia", "track": "Non-QP", "cf": _cy26.anesthesia_cf},
|
||
{"vintage": "CY2027 Proposed (final rule pending)", "family": "Anesthesia", "track": "Non-QP", "cf": _prop.anesthesia_cf},
|
||
{"vintage": "CY2027 Proposed (final rule pending)", "family": "Anesthesia", "track": "QP", "cf": _prop.anesthesia_cf_qp},
|
||
# NOTE: RULES[2026].anesthesia_cf models the non-QP anesthesia CF
|
||
# only — the CY2026 Final Rule anesthesia QP CF is not captured
|
||
# in the registry (see pfs.rules module docstring), so that bar
|
||
# is deliberately omitted rather than guessed. No CY2027-final
|
||
# bars exist anywhere in this table — there is no Final Rule yet.
|
||
]
|
||
cf_walk = pl.DataFrame(_rows)
|
||
cf_walk
|
||
return (cf_walk,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(alt, cf_walk, mo):
|
||
_vintage_order = ["CY2026 Final", "CY2027 Proposed (final rule pending)"]
|
||
_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=300, height=280)
|
||
.facet(column=alt.Column("family:N", title=None))
|
||
.resolve_scale(y="independent")
|
||
.properties(title="CY2026 Final vs. CY2027 Proposed PFS Conversion Factors")
|
||
)
|
||
|
||
mo.vstack(
|
||
[
|
||
cf_chart,
|
||
mo.md("""
|
||
*CY2026 Final anesthesia QP is not shown — that CF is not
|
||
captured in the `pfs.rules` registry (see module docstring).
|
||
No CY2027-final bars appear anywhere in this notebook — the
|
||
Final Rule (CMS-1848-F) has not been published.*
|
||
|
||
**Sources:** CY2026 Final Rule — 90 FR 49266. CY2027 NPRM —
|
||
91 FR 43842 (CMS-1848-P).
|
||
"""),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
### Budget-neutrality adjustor decomposition
|
||
|
||
CMS's own NPRM narrative (91 FR 44242) describes the CY2027 CF
|
||
derivation explicitly: start from the **CY2026 conversion factors
|
||
with the one-time 2.50% statutory increase backed out**, multiply
|
||
by the 0.53% budget-neutrality adjustment, then multiply by the
|
||
section 1848(d)(20) qualifying/nonqualifying-APM annual update
|
||
(+0.75% / +0.25%). Reproducing that arithmetic against the
|
||
registry's actual CY2026-final and CY2027-proposed CFs is a useful
|
||
cross-check that the registry's transcription is internally
|
||
consistent with CMS's narrative — for the **standard** CF only;
|
||
the anesthesia CFs reflect "the same overall PFS adjustments with
|
||
the addition of anesthesia-specific PE and MP adjustments" (91 FR
|
||
44242) that are not modeled as separate registry fields, so they
|
||
are excluded from this reconstruction rather than approximated.
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(RULES, mo, pl, proposed_for):
|
||
_cy26 = RULES[2026]
|
||
_prop = proposed_for(2027)
|
||
|
||
# CMS's own stated CF-derivation components for CY2027 (91 FR 44242,
|
||
# file lines ~39088-39106): back the CY2026 CFs out of the one-time
|
||
# 2.50% statutory increase, then reapply BN + the annual update.
|
||
# NONE of these bare percentages is a field anywhere in `pfs.rules`
|
||
# or `qpp` — there is nothing to attribute-access for them. 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_BACKOUT = 1.0250 # CY2026's one-time +2.50% statutory increase, backed out per 91 FR 44242
|
||
_ANNUAL_UPDATE_MULT = {
|
||
"Non-QP": 1.0025, # +0.25%/yr nonqualifying-APM update, sec. 1848(d)(20), 91 FR 44242
|
||
"QP": 1.0075, # +0.75%/yr qualifying-APM update, sec. 1848(d)(20), 91 FR 44242
|
||
}
|
||
|
||
def _reconstruct(cy2026_final_cf, track, bn_adjustor):
|
||
return (cy2026_final_cf / _ONE_TIME_BACKOUT) * bn_adjustor * _ANNUAL_UPDATE_MULT[track]
|
||
|
||
_bn_rows = [
|
||
{
|
||
"track": "Non-QP",
|
||
"cy2026_final_cf": _cy26.conversion_factor,
|
||
"one_time_backout": _ONE_TIME_BACKOUT,
|
||
"bn_adjustor": _prop.budget_neutrality_adjustor,
|
||
"annual_update_mult": _ANNUAL_UPDATE_MULT["Non-QP"],
|
||
"reconstructed_cy2027_proposed_cf": round(
|
||
_reconstruct(_cy26.conversion_factor, "Non-QP", _prop.budget_neutrality_adjustor), 4
|
||
),
|
||
"registry_cy2027_proposed_cf": _prop.conversion_factor,
|
||
},
|
||
{
|
||
"track": "QP",
|
||
"cy2026_final_cf": _cy26.cf_qp,
|
||
"one_time_backout": _ONE_TIME_BACKOUT,
|
||
"bn_adjustor": _prop.budget_neutrality_adjustor,
|
||
"annual_update_mult": _ANNUAL_UPDATE_MULT["QP"],
|
||
"reconstructed_cy2027_proposed_cf": round(
|
||
_reconstruct(_cy26.cf_qp, "QP", _prop.budget_neutrality_adjustor), 4
|
||
),
|
||
"registry_cy2027_proposed_cf": _prop.cf_qp,
|
||
},
|
||
]
|
||
bn_decomp = pl.DataFrame(_bn_rows).with_columns(
|
||
(pl.col("reconstructed_cy2027_proposed_cf") - pl.col("registry_cy2027_proposed_cf"))
|
||
.abs()
|
||
.round(4)
|
||
.alias("abs_diff")
|
||
)
|
||
|
||
mo.vstack(
|
||
[
|
||
bn_decomp,
|
||
mo.md(f"""
|
||
`reconstructed_cf = (CY2026_final_cf / one_time_backout) ×
|
||
bn_adjustor × annual_update_mult` — `bn_adjustor` and
|
||
`registry_cy2027_proposed_cf` are read live from
|
||
`RULES[2026]` / `proposed_for(2027)`. `one_time_backout`
|
||
({_ONE_TIME_BACKOUT}) and `annual_update_mult`
|
||
({_ANNUAL_UPDATE_MULT["Non-QP"]} non-QP /
|
||
{_ANNUAL_UPDATE_MULT["QP"]} QP) are **cited transcriptions
|
||
of CMS's NPRM narrative (91 FR 44242,
|
||
`data/fr_downloads/2026-14327.txt:39088-39106`)**, not
|
||
registry fields — no field in `pfs.rules` or `qpp` models
|
||
the 2.50%/0.53%/0.25%/0.75% components individually, only
|
||
their combined effect on `conversion_factor` / `cf_qp`.
|
||
"""),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo, proposed_for):
|
||
_prop = proposed_for(2027)
|
||
mo.vstack(
|
||
[
|
||
mo.md("### Caveat — a drafting error in the NPRM's own summary section"),
|
||
mo.callout(
|
||
mo.md(f"""
|
||
`pfs.rules.PROPOSED[2027].notes` documents a transcription
|
||
caveat that is worth surfacing here rather than only in
|
||
source comments — quoted live below, not retyped:
|
||
|
||
> {_prop.notes}
|
||
"""),
|
||
kind="warn",
|
||
),
|
||
]
|
||
)
|
||
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` filtered to `cms_rule_id = 'CMS-1848-P'` (14,518
|
||
rows as of this ingest) is compared against `pfs.rvu` for CY2026
|
||
(the current final baseline — there is no CY2027 final table to
|
||
compare against). Both tables are deduplicated to one row per
|
||
HCPCS base code (no modifier) via `QUALIFY row_number() ... = 1`
|
||
per the P36 convention, `status_code = 'A'` (actively priced), and
|
||
— for the proposed table specifically — a non-null non-facility PE
|
||
RVU, since a meaningful share 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, non_fac_pe_rvu, mp_rvu,
|
||
work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu
|
||
FROM pfs.rvu_proposed
|
||
WHERE cms_rule_id = 'CMS-1848-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_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.total_nf_rvu AS total_nf_rvu_proposed,
|
||
f26.total_nf_rvu AS total_nf_rvu_2026final
|
||
FROM proposed p
|
||
JOIN final_2026 f26 USING (hcpcs)
|
||
""")
|
||
rvu_delta_raw
|
||
return (rvu_delta_raw,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(RULES, pl, proposed_for, rvu_delta_raw):
|
||
_cf_2026_final = RULES[2026].conversion_factor
|
||
_cf_2027_proposed = proposed_for(2027).conversion_factor
|
||
|
||
rvu_delta = rvu_delta_raw.with_columns(
|
||
(pl.col("total_nf_rvu_proposed") * _cf_2027_proposed).round(2).alias("dollar_2027proposed"),
|
||
(pl.col("total_nf_rvu_2026final") * _cf_2026_final).round(2).alias("dollar_2026final"),
|
||
).with_columns(
|
||
(pl.col("dollar_2027proposed") - pl.col("dollar_2026final")).round(2).alias("delta_vs_2026final"),
|
||
)
|
||
rvu_delta
|
||
return (rvu_delta,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(AMBER, RULES, TEAL, alt, mo, proposed_for, rvu_delta):
|
||
_n = 20
|
||
|
||
_winners = rvu_delta.sort("delta_vs_2026final", descending=True).head(_n)
|
||
_losers = rvu_delta.sort("delta_vs_2026final", 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["delta_vs_2026final"].apply(lambda v: "Gain" if v >= 0 else "Loss")
|
||
|
||
winners_losers_chart = (
|
||
alt.Chart(_plot_df)
|
||
.mark_bar()
|
||
.encode(
|
||
x=alt.X("delta_vs_2026final:Q", title="$ change, non-QP CF (national unadjusted, GPCI=1.0)"),
|
||
y=alt.Y("label:N", title=None, sort=alt.SortField(field="delta_vs_2026final", 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("delta_vs_2026final:Q", format="$.2f")],
|
||
)
|
||
.properties(
|
||
title="Top 20 Winners / Top 20 Losers — CY2027 Proposed vs. CY2026 Final",
|
||
width=700,
|
||
height=600,
|
||
)
|
||
)
|
||
|
||
mo.vstack(
|
||
[
|
||
winners_losers_chart,
|
||
mo.md(f"""
|
||
**Sources:** `pfs.rvu_proposed` (`CMS-1848-P`) — CY2027 NPRM,
|
||
{proposed_for(2027).federal_register_citation}. Baseline —
|
||
`pfs.rvu` CY2026 Final ({RULES[2026].federal_register_citation}).
|
||
Both sides priced with each vintage's own non-QP standard CF
|
||
— this delta blends RVU-table changes with the proposed CF
|
||
change, it is not an RVU-only comparison.
|
||
"""),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo, proposed_for, rvu_delta):
|
||
mo.vstack(
|
||
[
|
||
mo.md(f"""
|
||
**Full detail — {rvu_delta.height:,} codes** matched across
|
||
the `CMS-1848-P` proposed RVU table and CY2026-final RVU
|
||
table (search the table below by HCPCS or description).
|
||
"""),
|
||
mo.ui.table(
|
||
rvu_delta.sort("delta_vs_2026final").to_pandas(),
|
||
page_size=25,
|
||
label="RVU / Payment Deltas by HCPCS",
|
||
),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(con, mo, proposed_for):
|
||
_new = con.execute("""
|
||
SELECT count(*) FROM pfs.rvu_proposed p
|
||
WHERE p.cms_rule_id = 'CMS-1848-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=2026 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=2026 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-1848-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 the `CMS-1848-P`
|
||
> proposed RVU table with no CY2026-final counterpart (new/renumbered
|
||
> codes), and {_dropped} CY2026-final codes have no match in the
|
||
> proposed table (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-1848-P Addendum B,
|
||
{proposed_for(2027).federal_register_citation}. `pfs.rvu` — CY2026
|
||
PFS Final Rule Addendum B.
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
### Cross-NPRM check — CY2026 NPRM vs. CY2027 NPRM (optional)
|
||
|
||
`pfs.rvu_proposed` holds **two** NPRM partitions in the same table
|
||
— `CMS-1832-P` (CY2026 NPRM, 14,169 rows) and `CMS-1848-P` (CY2027
|
||
NPRM, 14,518 rows). For codes proposed in both rulemakings, this
|
||
compares total non-facility RVUs NPRM-to-NPRM — a coarse signal of
|
||
which codes CMS has been proposing to move for two consecutive
|
||
rulemaking cycles, independent of which CF applies.
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(q):
|
||
cross_nprm = q("""
|
||
WITH cy2026_nprm AS (
|
||
SELECT hcpcs, description, work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu
|
||
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
|
||
),
|
||
cy2027_nprm AS (
|
||
SELECT hcpcs, work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu
|
||
FROM pfs.rvu_proposed
|
||
WHERE cms_rule_id = 'CMS-1848-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
|
||
)
|
||
SELECT
|
||
a.hcpcs, a.description,
|
||
a.total_nf_rvu AS total_nf_rvu_cy2026_nprm,
|
||
b.total_nf_rvu AS total_nf_rvu_cy2027_nprm,
|
||
round(b.total_nf_rvu - a.total_nf_rvu, 4) AS rvu_delta_nprm_to_nprm
|
||
FROM cy2026_nprm a
|
||
JOIN cy2027_nprm b USING (hcpcs)
|
||
ORDER BY abs(b.total_nf_rvu - a.total_nf_rvu) DESC
|
||
LIMIT 10
|
||
""")
|
||
return (cross_nprm,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(cross_nprm, mo):
|
||
mo.vstack(
|
||
[
|
||
cross_nprm,
|
||
mo.md("""
|
||
**Sources:** `pfs.rvu_proposed` — `CMS-1832-P` (90 FR 32352)
|
||
and `CMS-1848-P` (91 FR 43842) partitions of the same table.
|
||
Total non-facility RVUs only — no dollar conversion, since
|
||
the two NPRMs' CFs are not directly comparable across a
|
||
one-year gap without also re-basing for the RVU-neutral
|
||
budget-neutrality adjustment each rulemaking applies.
|
||
"""),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
# ── 3. Advanced APM ─────────────────────────────────────────────────────
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
## 3. 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`). The CY2027 NPRM's own regulatory-impact-analysis recital
|
||
covers **QP Performance Period 2027 / payment year 2029** —
|
||
`qpp.QPP[2027]`, reachable as `qpp.for_payment_year(2029)`.
|
||
|
||
`qpp.QPP`'s payment-year keying was corrected under task #620
|
||
shortly before this notebook was written — thresholds are keyed to
|
||
**payment year**, not performance year, and every table below
|
||
labels both explicitly so the correction is visible rather than
|
||
silently assumed.
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(for_payment_year, mo):
|
||
_governs_2029 = for_payment_year(2029)
|
||
mo.md(f"""
|
||
`qpp.for_payment_year(2029)` resolves to QP Performance Period
|
||
**{_governs_2029.performance_year}** ({_governs_2029.citation}) —
|
||
the performance period whose determinations govern the payment
|
||
year that the CY2027 NPRM's proposed conversion factors apply to.
|
||
`qp_cf_applies` is **{_governs_2029.qp_cf_applies}** for this
|
||
performance period.
|
||
""")
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(QPP, pl):
|
||
_rows = []
|
||
for _perf_year, _qy in sorted(QPP.items()):
|
||
_prop = _qy.proposed
|
||
_rows.append(
|
||
{
|
||
"qp_performance_period": _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,
|
||
"has_nprm_proposal": _prop is not None,
|
||
"citation": _qy.citation,
|
||
}
|
||
)
|
||
apm_thresholds = pl.DataFrame(_rows)
|
||
apm_thresholds
|
||
return (apm_thresholds,)
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(QPP, mo):
|
||
mo.md(f"""
|
||
Columns are labeled `qp_performance_period` (the year Advanced-APM
|
||
participation is measured) and `payment_year` (the year the
|
||
resulting QP status is applied) explicitly and separately — every
|
||
threshold in this table governs by **`payment_year`**, per 42 CFR
|
||
414.1430(a) and section 1833(z)(2) of the Act. `QPP[2027]`
|
||
(payment year {QPP[2027].payment_year}) is the entry this
|
||
notebook's CF walk (Section 1) corresponds to.
|
||
""")
|
||
return
|
||
|
||
|
||
@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=["qp_performance_period", "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 (this
|
||
notebook's prior year) 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** — CMS-1848-P proposes to codify that
|
||
revival into 42 CFR 414.1450(b)(1) (91 FR 44218-44219,
|
||
44286). Payment year 2029 (`QPP[2027]`, the year this
|
||
notebook's own CF walk applies to) reverts to **no**
|
||
incentive — the amendatory text lists no applicable
|
||
percentage for payment year 2029 at all. So the pattern
|
||
across 2025-2029 is paid / paid / gap / revived / gap, not
|
||
a single cutoff.
|
||
|
||
**Sources:** `qpp.QPP` performance years 2023-2027
|
||
({_citations}).
|
||
"""),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(QPP, mo):
|
||
_prop = QPP[2027].proposed
|
||
mo.vstack(
|
||
[
|
||
mo.md(f"""
|
||
### CMS-1848-P proposed QPP changes — proposals only, no Final Rule disposition yet
|
||
|
||
`qpp.QPP[2027].proposed.changes` ({_prop.federal_register_citation},
|
||
{_prop.cms_rule_id}) is the single source of truth for the
|
||
narrative below — quoted live, not retyped. Unlike the
|
||
CY2026 notebook's disposition table, there is **no**
|
||
finalized/not-finalized column here: CMS-1848-F has not
|
||
been published.
|
||
"""),
|
||
mo.callout(mo.md(_prop.changes), kind="info"),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
### What QP status is worth in dollars (CY2027 proposed) — 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`,
|
||
using **CY2026-final RVUs** (the current baseline; CY2027 has no
|
||
finalized RVU table yet) priced at the **CY2027 NPRM's proposed**
|
||
QP and non-QP conversion factors:
|
||
|
||
- **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 _(pl, proposed_for, 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')
|
||
""")
|
||
|
||
_prop = proposed_for(2027)
|
||
_rows = []
|
||
for _r in _example_rvu.iter_rows(named=True):
|
||
_pay_nonqp = round(_r["total_rvu"] * _prop.conversion_factor, 2)
|
||
_pay_qp = round(_r["total_rvu"] * _prop.cf_qp, 2)
|
||
_rows.append(
|
||
{
|
||
"hcpcs": _r["hcpcs"],
|
||
"description": _r["description"],
|
||
"total_rvu_cy2026final": _r["total_rvu"],
|
||
"non_qp_payment_cy2027proposed": _pay_nonqp,
|
||
"qp_payment_cy2027proposed": _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, proposed_for, qp_differential):
|
||
differential_chart = (
|
||
alt.Chart(qp_differential.to_pandas())
|
||
.mark_bar()
|
||
.encode(
|
||
x=alt.X("hcpcs:N", title="HCPCS"),
|
||
y=alt.Y("qp_differential:Q", title="QP minus non-QP payment ($, national unadjusted)"),
|
||
tooltip=["hcpcs", "description", alt.Tooltip("qp_differential:Q", format="$.2f")],
|
||
)
|
||
.properties(title="Dollar Value of QP Status — CY2027 Proposed (final rule pending)", width=500, height=320)
|
||
)
|
||
|
||
mo.vstack(
|
||
[
|
||
differential_chart,
|
||
mo.md(f"""
|
||
**Sources:** RVUs — `pfs.rvu`, CY2026 Final (90 FR 49266).
|
||
CFs — `proposed_for(2027)`, CY2027 NPRM
|
||
({proposed_for(2027).federal_register_citation},
|
||
{proposed_for(2027).cms_rule_id}) — final rule pending.
|
||
National unadjusted means GPCI = 1.0 — actual payment
|
||
varies by locality.
|
||
"""),
|
||
]
|
||
)
|
||
return
|
||
|
||
|
||
# ── 4. Provenance ───────────────────────────────────────────────────────
|
||
|
||
|
||
@app.cell(hide_code=True)
|
||
def _(mo):
|
||
mo.md("""
|
||
## 4. Provenance & Comment-Period Status
|
||
|
||
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, proposed_for):
|
||
from datetime import date
|
||
|
||
_prop = proposed_for(2027)
|
||
_close = _prop.comment_close
|
||
_today = date.today()
|
||
_open = _today <= _close
|
||
_status = "OPEN" if _open else "CLOSED"
|
||
|
||
mo.callout(
|
||
mo.md(f"""
|
||
**Comment period: {_status}** — {_prop.cms_rule_id} ({_prop.federal_register_citation})
|
||
published {_prop.published.isoformat()}, comment period closes
|
||
**{_close.isoformat()}** (`proposed_for(2027).comment_close`,
|
||
read live from the registry; today is {_today.isoformat()}).
|
||
"""),
|
||
kind="success" if _open else "neutral",
|
||
)
|
||
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()
|