feat: skin sub pricing notebook — RVU, locality fees, and ASP over time
Some checks failed
CI / skinny-install (bls) (push) Successful in 22s
CI / skinny-install (ccw) (push) Successful in 37s
CI / skinny-install (cli) (push) Successful in 33s
CI / skinny-install (cms) (push) Successful in 23s
CI / skinny-install (conf) (push) Successful in 27s
CI / skinny-install (opps) (push) Successful in 30s
CI / skinny-install (perf) (push) Successful in 34s
CI / skinny-install (aco) (pull_request) Successful in 45s
CI / lint-test (pull_request) Successful in 1m17s
CI / skinny-install (api) (pull_request) Successful in 30s
CI / skinny-install (bib) (pull_request) Successful in 31s
CI / skinny-install (bls) (pull_request) Successful in 26s
CI / skinny-install (ccw) (pull_request) Successful in 31s
CI / skinny-install (cli) (pull_request) Successful in 32s
CI / skinny-install (cms) (pull_request) Successful in 33s
CI / skinny-install (aco) (push) Successful in 49s
CI / lint-test (push) Successful in 1m15s
CI / skinny-install (api) (push) Successful in 27s
CI / skinny-install (bib) (push) Successful in 32s
CI / skinny-install (bcda) (push) Successful in 36s
CI / skinny-install (pfs) (push) Successful in 37s
CI / skinny-install (rex) (push) Successful in 31s
CI / skinny-install (bcda) (pull_request) Successful in 27s
CI / skinny-install (conf) (pull_request) Successful in 28s
CI / skinny-install (opps) (pull_request) Successful in 33s
CI / skinny-install (perf) (pull_request) Successful in 34s
CI / skinny-install (pfs) (pull_request) Successful in 30s
Infra CI / notebooks (push) Successful in 7s
CI / skinny-install (rex) (pull_request) Successful in 35s
Infra CI / api (push) Successful in 6s
Infra CI / zotero (push) Successful in 6s
Infra CI / docs (push) Failing after 6s
Infra CI / mc (push) Successful in 6s
Infra CI / notebooks (pull_request) Successful in 6s
Infra CI / zotero (pull_request) Successful in 6s
Infra CI / docs (pull_request) Failing after 7s
Infra CI / api (pull_request) Successful in 7s
Infra CI / mc (pull_request) Successful in 7s

New marimo notebook tracking skin substitute economics across payment
systems and years:

1. Application code RVUs (15271-15278) over time
2. Locality-based PFS fees by region (MAC-aware carrier join)
3. Regional comparison for 15271 across 10 high-volume areas
4. Quarterly ASP payment limits for top 15 products with $127.28
   flat-rate reference line
5. OPPS payment method timeline (pass-through → high/low → flat rate)
6. Total episode cost: product ASP + application fee for 25cm² wound
7. Flat-rate impact analysis: winners and losers under CY2026
8. Summary table: fee range spread across all localities by year

Data sources: pfs.rvu, pfs.carrier_locality, pfs.gpci,
skin_subs.asp_quarterly, opps.skin_sub_addendum_b

refs #246
This commit is contained in:
kert
2026-03-26 21:42:05 -04:00
parent 7015674238
commit 941f78d017

View File

@@ -0,0 +1,502 @@
import marimo
__generated_with = "0.20.2"
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("""
# Skin Substitute Pricing Over Time
Tracks skin substitute application code RVUs and locality-based
payment amounts (PFS) alongside quarterly ASP drug pricing, layered
over the OPPS payment method timeline (pass-through → high/low →
flat rate).
**Data sources:**
- `pfs.rvu` — Work, PE, MP RVUs for application codes 15271–15278
- `pfs.carrier_locality` — CMS-published locality fees (ground truth)
- `pfs.gpci` — Geographic practice cost indices by MAC/locality
- `skin_subs.asp_quarterly` — Quarterly ASP + 6% payment limits
- `opps.skin_sub_addendum_b` — OPPS status indicators and APC rates
""")
return
@app.cell(hide_code=True)
def _():
import altair as alt
import polars as pl
from conf import connect
con = connect.duckdb()
def q(sql):
return con.execute(sql).pl()
return alt, con, pl, q
# ── 1. Application code RVUs over time ──────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 1. Application Code RVUs Over Time
CPT 15271–15278 are the procedure codes for applying skin
substitutes. Their RVUs determine the surgeon's payment
independent of the product cost.
""")
return
@app.cell
def _(alt, q):
rvu_ts = q("""
SELECT year, hcpcs,
CASE hcpcs
WHEN '15271' THEN 'Trunk/limbs <100cm² (initial)'
WHEN '15272' THEN 'Trunk/limbs <100cm² (add-on)'
WHEN '15273' THEN 'Trunk/limbs ≥100cm² (initial)'
WHEN '15274' THEN 'Trunk/limbs ≥100cm² (add-on)'
WHEN '15275' THEN 'Face/hands/feet <100cm² (initial)'
WHEN '15276' THEN 'Face/hands/feet <100cm² (add-on)'
WHEN '15277' THEN 'Face/hands/feet ≥100cm² (initial)'
WHEN '15278' THEN 'Face/hands/feet ≥100cm² (add-on)'
END as description,
work_rvu, non_fac_pe_rvu, fac_pe_rvu, mp_rvu,
work_rvu + non_fac_pe_rvu + mp_rvu as non_fac_total,
work_rvu + fac_pe_rvu + mp_rvu as fac_total
FROM pfs.rvu
WHERE hcpcs IN ('15271','15272','15273','15274',
'15275','15276','15277','15278')
ORDER BY year, hcpcs
""")
rvu_chart = (
alt.Chart(rvu_ts.to_pandas())
.mark_line(point=True)
.encode(
x=alt.X("year:O", title="Year"),
y=alt.Y("non_fac_total:Q", title="Total Non-Facility RVUs"),
color=alt.Color("description:N", title="Code"),
tooltip=["year", "hcpcs", "description",
"work_rvu", "non_fac_pe_rvu", "mp_rvu", "non_fac_total"],
)
.properties(title="Application Code RVUs (Non-Facility)", width=700, height=400)
)
rvu_chart
return (rvu_ts,)
# ── 2. Locality-based payment for application codes ─────────────────
@app.cell(hide_code=True)
def _(con, mo):
_regions = con.execute("""
SELECT DISTINCT locality_name
FROM pfs.gpci WHERE year = 2025
ORDER BY locality_name
""").fetchall()
region_picker = mo.ui.dropdown(
options={r[0]: r[0] for r in _regions},
value="MANHATTAN",
label="Region",
)
mo.md(f"""
## 2. Application Code Payment by Region
CMS-published carrier locality fees for skin sub application codes.
Select a region to see how payments vary geographically over time.
{region_picker}
""")
return (region_picker,)
@app.cell
def _(alt, pl, q, region_picker):
_region = region_picker.value
carrier_ts = q(f"""
SELECT c.year, c.hcpcs,
CASE c.hcpcs
WHEN '15271' THEN 'Trunk <100cm²'
WHEN '15272' THEN 'Trunk <100cm² add-on'
WHEN '15273' THEN 'Trunk ≥100cm²'
WHEN '15274' THEN 'Trunk ≥100cm² add-on'
WHEN '15275' THEN 'Face/hands <100cm²'
WHEN '15276' THEN 'Face/hands <100cm² add-on'
WHEN '15277' THEN 'Face/hands ≥100cm²'
WHEN '15278' THEN 'Face/hands ≥100cm² add-on'
END as description,
c.non_fac_fee, c.fac_fee,
g.locality_name
FROM pfs.carrier_locality c
JOIN pfs.gpci g ON c.mac = g.mac AND c.locality = g.locality AND c.year = g.year
WHERE c.hcpcs IN ('15271','15272','15273','15274',
'15275','15276','15277','15278')
AND g.locality_name = '{_region}'
ORDER BY c.year, c.hcpcs
""")
carrier_chart = (
alt.Chart(carrier_ts.to_pandas())
.mark_line(point=True)
.encode(
x=alt.X("year:O", title="Year"),
y=alt.Y("non_fac_fee:Q", title="Non-Facility Fee ($)"),
color=alt.Color("description:N", title="Code"),
tooltip=["year", "hcpcs", "description", "non_fac_fee",
"fac_fee", "locality_name"],
)
.properties(
title=f"Application Code Fees — {_region}",
width=700, height=400,
)
)
carrier_chart
return (carrier_ts,)
# ── 3. Regional comparison for a single code ────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 3. Regional Comparison — 15271 (Primary Application)
Non-facility fee for 15271 (trunk/limbs, <100cm², initial
application) across selected high-volume regions.
""")
return
@app.cell
def _(alt, q):
regions = [
"MANHATTAN", "ALASKA*", "REST OF FLORIDA", "REST OF TEXAS",
"REST OF CALIFORNIA", "CHICAGO", "DETROIT", "ATLANTA",
"HAWAII, GUAM", "SOUTH CAROLINA",
]
region_list = ", ".join(f"'{r}'" for r in regions)
regional_15271 = q(f"""
SELECT c.year, g.locality_name,
c.non_fac_fee
FROM pfs.carrier_locality c
JOIN pfs.gpci g ON c.mac = g.mac AND c.locality = g.locality AND c.year = g.year
WHERE c.hcpcs = '15271'
AND g.locality_name IN ({region_list})
ORDER BY c.year, g.locality_name
""")
regional_chart = (
alt.Chart(regional_15271.to_pandas())
.mark_line(point=True)
.encode(
x=alt.X("year:O", title="Year"),
y=alt.Y("non_fac_fee:Q", title="Non-Facility Fee ($)"),
color=alt.Color("locality_name:N", title="Region"),
tooltip=["year", "locality_name", "non_fac_fee"],
)
.properties(
title="15271 Non-Facility Fee — Regional Comparison",
width=700, height=400,
)
)
regional_chart
return
# ── 4. ASP product pricing over time ────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 4. ASP Drug Pricing Over Time
Quarterly ASP + 6% payment limits for skin substitute products.
The flat horizontal line at **$127.28** marks the CY2026 flat rate —
products above it lose revenue, products below it gain.
""")
return
@app.cell
def _(alt, pl, q):
# Top products by volume (most quarters of data)
top_products = q("""
SELECT hcpcs_code, short_description,
count(*) as quarters
FROM skin_subs.asp_quarterly
GROUP BY hcpcs_code, short_description
HAVING count(*) >= 8
ORDER BY quarters DESC
LIMIT 15
""")
top_codes = top_products.select("hcpcs_code").to_series().to_list()
code_list = ", ".join(f"'{c}'" for c in top_codes)
asp_ts = q(f"""
SELECT quarter, hcpcs_code, short_description,
payment_limit, asp_per_unit
FROM skin_subs.asp_quarterly
WHERE hcpcs_code IN ({code_list})
ORDER BY quarter, hcpcs_code
""")
flat_rate_rule = (
alt.Chart(pl.DataFrame({"y": [127.28]}).to_pandas())
.mark_rule(color="red", strokeDash=[4, 4], strokeWidth=2)
.encode(y="y:Q")
)
asp_lines = (
alt.Chart(asp_ts.to_pandas())
.mark_line()
.encode(
x=alt.X("quarter:O", title="Quarter",
axis=alt.Axis(labelAngle=-45, labelFontSize=8)),
y=alt.Y("payment_limit:Q", title="Payment Limit (ASP + 6%) $",
scale=alt.Scale(domainMax=800)),
color=alt.Color("short_description:N", title="Product"),
tooltip=["quarter", "hcpcs_code", "short_description",
"payment_limit", "asp_per_unit"],
)
)
asp_chart = (
(asp_lines + flat_rate_rule)
.properties(
title="ASP Quarterly Payment Limits — Top 15 Products",
width=700, height=450,
)
)
asp_chart
return
# ── 5. OPPS payment method timeline ─────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 5. OPPS Payment Method Timeline
How CMS has paid for skin substitutes in the outpatient setting:
| Period | Method | Detail |
|--------|--------|--------|
| Pre-2023 | Pass-through | ASP + 6% per unit |
| 2023–2025 | High/low split | High-cost: pass-through; Low-cost: packaged into APC |
| 2026+ | Flat rate | $127.28/cm² for all products |
The table below shows OPPS status indicators and payment rates
for skin sub codes over time.
""")
return
@app.cell
def _(q):
opps_ts = q("""
SELECT year, hcpcs, short_description,
status_indicator, apc, payment_rate
FROM opps.skin_sub_addendum_b
WHERE payment_rate IS NOT NULL
AND payment_rate > 0
ORDER BY year, hcpcs
""")
opps_ts
return (opps_ts,)
# ── 6. Combined view: ASP + application fee ─────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 6. Total Episode Cost: Product + Application
For a typical 25cm² wound, the total Medicare payment is:
- **Product cost**: ASP + 6% payment limit × 25 units
- **Application fee**: 15271 non-facility fee (PFS, locality-specific)
- **Post-2026**: $127.28 × 25 = $3,182 flat + application fee
This chart shows the combined cost trend for selected products.
""")
return
@app.cell
def _(alt, pl, q, region_picker):
_region = region_picker.value
# Get 15271 fee for the selected region, all years
app_fee = q(f"""
SELECT c.year, c.non_fac_fee as application_fee
FROM pfs.carrier_locality c
JOIN pfs.gpci g ON c.mac = g.mac AND c.locality = g.locality AND c.year = g.year
WHERE c.hcpcs = '15271'
AND g.locality_name = '{_region}'
""")
# Get annual ASP for top products (use Q1 of each year)
episode_data = q("""
SELECT
CAST(substr(quarter, 1, 4) AS INTEGER) as year,
hcpcs_code, short_description,
payment_limit,
payment_limit * 25 as product_cost_25cm2
FROM skin_subs.asp_quarterly
WHERE substr(quarter, 6, 2) = 'Q1'
AND hcpcs_code IN ('Q4101','Q4186','Q4132','Q4116','Q4100')
ORDER BY year, hcpcs_code
""")
# Join to get total episode cost
combined = (
episode_data.join(app_fee, on="year", how="left")
.with_columns(
(pl.col("product_cost_25cm2") + pl.col("application_fee").fill_null(0))
.alias("total_episode_cost")
)
)
flat_line = (
alt.Chart(pl.DataFrame({"y": [127.28 * 25]}).to_pandas())
.mark_rule(color="red", strokeDash=[4, 4], strokeWidth=2)
.encode(y="y:Q")
)
episode_lines = (
alt.Chart(combined.to_pandas())
.mark_line(point=True)
.encode(
x=alt.X("year:O", title="Year"),
y=alt.Y("total_episode_cost:Q", title="Total Episode Cost ($)"),
color=alt.Color("short_description:N", title="Product"),
tooltip=["year", "hcpcs_code", "short_description",
"product_cost_25cm2", "application_fee",
"total_episode_cost"],
)
)
episode_chart = (
(episode_lines + flat_line)
.properties(
title=f"Total Episode Cost (25cm² wound) — {_region}",
width=700, height=400,
)
)
episode_chart
return
# ── 7. Flat-rate impact analysis ────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 7. Flat-Rate Impact: Winners and Losers
Products with ASP + 6% above $127.28 lose revenue under the 2026
flat rate; those below gain. This chart shows the latest quarter's
payment limit vs. the flat rate.
""")
return
@app.cell
def _(alt, pl, q):
impact = q("""
SELECT hcpcs_code, short_description,
payment_limit,
127.28 as flat_rate,
payment_limit - 127.28 as delta,
CASE
WHEN payment_limit > 127.28 THEN 'loses'
WHEN payment_limit < 127.28 THEN 'gains'
ELSE 'neutral'
END as impact
FROM skin_subs.asp_quarterly
WHERE quarter = (SELECT max(quarter) FROM skin_subs.asp_quarterly)
ORDER BY delta DESC
""")
impact_chart = (
alt.Chart(impact.to_pandas())
.mark_bar()
.encode(
x=alt.X("delta:Q", title="Payment Limit − $127.28 Flat Rate"),
y=alt.Y("short_description:N", title="", sort="-x",
axis=alt.Axis(labelLimit=300)),
color=alt.Color("impact:N",
scale=alt.Scale(
domain=["loses", "gains", "neutral"],
range=["#d62728", "#2ca02c", "#7f7f7f"],
),
title="Impact"),
tooltip=["hcpcs_code", "short_description",
"payment_limit", "flat_rate", "delta"],
)
.properties(title="CY2026 Flat-Rate Impact by Product", width=700)
)
impact_chart
return
# ── 8. Summary table ────────────────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 8. Summary: Application Code Fee Range by Year
Min, median, and max non-facility fee across all MAC/locality
combinations for application code 15271.
""")
return
@app.cell
def _(q):
fee_summary = q("""
SELECT c.year,
count(*) as localities,
round(min(c.non_fac_fee), 2) as min_fee,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY c.non_fac_fee), 2) as median_fee,
round(max(c.non_fac_fee), 2) as max_fee,
round(max(c.non_fac_fee) - min(c.non_fac_fee), 2) as fee_spread
FROM pfs.carrier_locality c
WHERE c.hcpcs = '15271'
GROUP BY c.year
ORDER BY c.year
""")
fee_summary
return
if __name__ == "__main__":
app.run()