feat: beneficiary cost sharing notebook — coinsurance trends and CY2026 impact
Some checks failed
CI / skinny-install (aco) (push) Successful in 51s
CI / lint-test (push) Successful in 1m18s
CI / skinny-install (api) (push) Successful in 26s
CI / skinny-install (cli) (push) Successful in 32s
CI / skinny-install (cms) (push) Successful in 29s
CI / skinny-install (conf) (push) Successful in 31s
CI / skinny-install (opps) (push) Successful in 32s
CI / skinny-install (bcda) (push) Successful in 33s
CI / skinny-install (bib) (push) Successful in 34s
CI / skinny-install (bls) (push) Successful in 24s
CI / skinny-install (ccw) (push) Successful in 31s
CI / skinny-install (rex) (push) Successful in 40s
CI / skinny-install (aco) (pull_request) Successful in 48s
CI / lint-test (pull_request) Successful in 1m24s
CI / skinny-install (bls) (pull_request) Successful in 27s
CI / skinny-install (bib) (pull_request) Successful in 31s
CI / skinny-install (ccw) (pull_request) Successful in 26s
CI / skinny-install (cli) (pull_request) Successful in 32s
Infra CI / zotero (push) Successful in 6s
Infra CI / docs (push) Failing after 5s
Infra CI / api (push) Successful in 6s
Infra CI / notebooks (pull_request) Successful in 6s
CI / skinny-install (perf) (push) Successful in 33s
CI / skinny-install (pfs) (push) Successful in 32s
CI / skinny-install (bcda) (pull_request) Successful in 29s
CI / skinny-install (api) (pull_request) Successful in 32s
CI / skinny-install (cms) (pull_request) Successful in 28s
CI / skinny-install (conf) (pull_request) Successful in 27s
CI / skinny-install (opps) (pull_request) Successful in 28s
CI / skinny-install (perf) (pull_request) Successful in 30s
CI / skinny-install (pfs) (pull_request) Successful in 33s
CI / skinny-install (rex) (pull_request) Successful in 29s
Infra CI / notebooks (push) Successful in 8s
Infra CI / mc (push) Successful in 6s
Infra CI / zotero (pull_request) Successful in 7s
Infra CI / docs (pull_request) Failing after 7s
Infra CI / api (pull_request) Successful in 7s
Infra CI / mc (pull_request) Successful in 8s

Tracks skin substitute beneficiary out-of-pocket costs over time:

1. Application fee coinsurance by region (20% of PFS carrier fee)
2. Product coinsurance trajectory (20% of ASP+6% per cm²)
3. Total episode cost sharing for 25cm² wound (product + application)
4. OPPS copayment collapse: $105-366 (2021-2025) → $25.43 flat (2026)
5. Winners/losers: high-ASP products save, low-ASP products pay more
6. Episode scenarios: Apligraf, EpiFix, GrafixCore before/after CY2026
7. Part B deductible context ($147 in 2015 → $257 in 2025-2026)
8. Key takeaways

refs #246
This commit is contained in:
kert
2026-03-26 22:56:44 -04:00
parent 0d6e191fa4
commit 47a18ee458

View File

@@ -0,0 +1,512 @@
import marimo
__generated_with = "0.21.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("""
# Beneficiary Cost Sharing for Skin Substitutes
Medicare Part B beneficiaries pay **20% coinsurance** on both the
application procedure (PFS) and the product (ASP + 6%). This
notebook tracks how cost sharing has changed over time and how
the CY2026 reclassification affects out-of-pocket exposure.
**Cost-sharing components:**
- **Application fee**: 20% of PFS carrier locality fee (15271–15278)
- **Product cost**: 20% of ASP + 6% payment limit × units
- **OPPS copayment**: minimum unadjusted copayment per APC (hospital outpatient)
- **Limiting charge**: non-participating providers may charge up to 115% of fee schedule
**CY2026 change:** All skin subs move to a flat $127.28/cm². OPPS copay
drops from ~$165–$366 per code to $25.43 flat. Product coinsurance
collapses to $25.43/unit regardless of actual ASP.
""")
return
@app.cell(hide_code=True)
def _():
import altair as alt
import polars as pl
from conf import connect
from pfs.rules import RULES
con = connect.duckdb()
def q(sql):
return con.execute(sql).pl()
SKIN_CODES = "('15271','15272','15273','15274','15275','15276','15277','15278')"
# Part B deductible history (published by CMS annually)
DEDUCTIBLES = {
2015: 147.00, 2016: 166.00, 2017: 183.00, 2018: 183.00,
2019: 185.00, 2020: 198.00, 2021: 203.00, 2022: 233.00,
2023: 226.00, 2024: 240.00, 2025: 257.00, 2026: 257.00,
}
return DEDUCTIBLES, RULES, SKIN_CODES, alt, con, pl, q
# ── 1. Application fee coinsurance over time ─────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 1. Application Fee Coinsurance by Region
The beneficiary pays 20% of the PFS-approved amount for the
application procedure. This varies by locality and year.
""")
return
@app.cell(hide_code=True)
def _(alt, q):
regions = [
"MANHATTAN", "REST OF FLORIDA", "REST OF TEXAS",
"REST OF CALIFORNIA", "SOUTH CAROLINA",
]
region_list = ", ".join(f"'{r}'" for r in regions)
app_coinsurance = q(f"""
SELECT c.year, g.locality_name,
c.non_fac_fee,
round(c.non_fac_fee * 0.20, 2) as bene_coinsurance,
c.non_fac_limiting_charge,
round(c.non_fac_limiting_charge * 0.20, 2) as bene_limiting_coinsurance
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
""")
app_chart = (
alt.Chart(app_coinsurance.to_pandas())
.mark_line(point=True)
.encode(
x=alt.X("year:O", title="Year"),
y=alt.Y("bene_coinsurance:Q", title="Beneficiary Coinsurance ($)"),
color=alt.Color("locality_name:N", title="Region"),
tooltip=["year", "locality_name", "non_fac_fee",
"bene_coinsurance", "non_fac_limiting_charge"],
)
.properties(
title="15271 Application — Beneficiary 20% Coinsurance by Region",
width=700, height=350,
)
)
app_chart
return
# ── 2. Product coinsurance trajectory ────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 2. Product Coinsurance Over Time (Per Unit)
The beneficiary pays 20% of the ASP + 6% payment limit **per cm²**.
For high-ASP products, this adds up fast — a 25cm² application of
a $150/cm² product means $750 in coinsurance just for the product.
The red dashed line shows the CY2026 flat-rate coinsurance:
20% × $127.28 = **$25.46/cm²**.
""")
return
@app.cell(hide_code=True)
def _(alt, pl, q):
product_coins = q("""
SELECT quarter, hcpcs_code, short_description,
payment_limit,
round(payment_limit * 0.20, 2) as bene_per_unit
FROM skin_subs.asp_quarterly
WHERE hcpcs_code IN ('Q4101','Q4186','Q4132','Q4116','Q4100')
ORDER BY quarter, hcpcs_code
""")
flat_coins = (
alt.Chart(pl.DataFrame({"y": [127.28 * 0.20]}).to_pandas())
.mark_rule(color="red", strokeDash=[4, 4], strokeWidth=2)
.encode(y="y:Q")
)
product_lines = (
alt.Chart(product_coins.to_pandas())
.mark_line()
.encode(
x=alt.X("quarter:O", title="Quarter",
axis=alt.Axis(labelAngle=-45, labelFontSize=8)),
y=alt.Y("bene_per_unit:Q", title="Beneficiary Coinsurance per cm² ($)"),
color=alt.Color("short_description:N", title="Product"),
tooltip=["quarter", "hcpcs_code", "short_description",
"payment_limit", "bene_per_unit"],
)
)
product_chart = (
(product_lines + flat_coins)
.properties(
title="Product Coinsurance per Unit (20% of ASP + 6%)",
width=700, height=400,
)
)
product_chart
return
# ── 3. Total episode cost sharing ────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 3. Total Episode Cost Sharing (25cm² Wound)
For a typical 25cm² wound treated in an office setting, the
beneficiary's total out-of-pocket is:
```
total = 20% × application_fee + 20% × (ASP+6% × 25 units)
```
This chart shows how that total has changed over time for
selected products, with the post-2026 flat-rate equivalent shown.
""")
return
@app.cell(hide_code=True)
def _(alt, pl, q):
# Use Q1 of each year for annual comparison
episode_sharing = q("""
WITH asp_annual AS (
SELECT CAST(substr(quarter, 1, 4) AS INTEGER) as year,
hcpcs_code, short_description,
payment_limit
FROM skin_subs.asp_quarterly
WHERE substr(quarter, 6, 2) = 'Q1'
AND hcpcs_code IN ('Q4101','Q4186','Q4132','Q4116')
),
app_fee AS (
SELECT c.year, avg(c.non_fac_fee) as avg_app_fee
FROM pfs.carrier_locality c
WHERE c.hcpcs = '15271'
GROUP BY c.year
)
SELECT a.year, a.hcpcs_code, a.short_description,
round(a.payment_limit, 2) as asp_per_unit,
round(f.avg_app_fee, 2) as avg_app_fee,
round(f.avg_app_fee * 0.20, 2) as app_coinsurance,
round(a.payment_limit * 25 * 0.20, 2) as product_coinsurance_25cm,
round(f.avg_app_fee * 0.20 + a.payment_limit * 25 * 0.20, 2) as total_bene_cost
FROM asp_annual a
LEFT JOIN app_fee f ON a.year = f.year
ORDER BY a.year, a.hcpcs_code
""")
flat_episode = 127.28 * 25 * 0.20 # $636.40 product + ~$28 application
flat_line = (
alt.Chart(pl.DataFrame({"y": [flat_episode]}).to_pandas())
.mark_rule(color="red", strokeDash=[4, 4], strokeWidth=2)
.encode(y="y:Q")
)
episode_lines = (
alt.Chart(episode_sharing.to_pandas())
.mark_line(point=True)
.encode(
x=alt.X("year:O", title="Year"),
y=alt.Y("total_bene_cost:Q",
title="Beneficiary Total Cost Sharing ($)"),
color=alt.Color("short_description:N", title="Product"),
tooltip=["year", "hcpcs_code", "short_description",
"asp_per_unit", "app_coinsurance",
"product_coinsurance_25cm", "total_bene_cost"],
)
)
episode_chart = (
(episode_lines + flat_line)
.properties(
title="Total Beneficiary Cost Sharing — 25cm² Wound Episode",
width=700, height=400,
)
)
episode_chart
return
# ── 4. OPPS copayment collapse ───────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 4. OPPS Copayment Collapse in CY2026
In the hospital outpatient setting (HOPD), beneficiaries pay a
minimum unadjusted copayment per APC. For skin substitutes:
- **2021–2025**: Copays ranged from $105–$366 depending on the
product's APC assignment (high-cost vs. low-cost categories)
- **CY2026**: All products reclassified to a single flat rate
with copay of **$25.43** — a >85% reduction
This is the most dramatic beneficiary cost-sharing change in
the CY2026 reclassification.
""")
return
@app.cell(hide_code=True)
def _(alt, q):
opps_copay = q("""
SELECT year,
count(*) as products,
round(avg(minimum_unadjusted_copayment), 2) as avg_copay,
round(min(minimum_unadjusted_copayment), 2) as min_copay,
round(max(minimum_unadjusted_copayment), 2) as max_copay,
round(max(minimum_unadjusted_copayment) -
min(minimum_unadjusted_copayment), 2) as copay_spread
FROM opps.skin_sub_addendum_b
WHERE minimum_unadjusted_copayment > 0
GROUP BY year ORDER BY year
""")
copay_chart = (
alt.Chart(opps_copay.to_pandas())
.mark_bar()
.encode(
x=alt.X("year:O", title="Year"),
y=alt.Y("avg_copay:Q", title="Average OPPS Copayment ($)"),
color=alt.condition(
alt.datum.year == 2026,
alt.value("#2ca02c"),
alt.value("#1f77b4"),
),
tooltip=["year", "products", "avg_copay", "min_copay",
"max_copay", "copay_spread"],
)
.properties(
title="OPPS Minimum Unadjusted Copayment — Skin Substitutes",
width=700, height=300,
)
)
mo.vstack([copay_chart, opps_copay])
return
# ── 5. Winners and losers: beneficiary perspective ────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 5. Beneficiary Impact: Who Pays More, Who Pays Less?
Under the flat rate, beneficiaries using **high-ASP products**
(EpiFix, GrafixCore) see their per-unit coinsurance drop
dramatically. Those using **low-ASP products** (Apligraf at
~$30/unit) see coinsurance rise from ~$6 to ~$25.
This is a wealth transfer: beneficiaries who previously used
expensive products benefit; those with cheaper products pay more.
""")
return
@app.cell(hide_code=True)
def _(alt, q):
bene_impact = q("""
SELECT hcpcs_code, short_description,
payment_limit as current_asp_payment,
round(payment_limit * 0.20, 2) as current_bene_per_unit,
127.28 as flat_rate,
round(127.28 * 0.20, 2) as flat_bene_per_unit,
round(127.28 * 0.20 - payment_limit * 0.20, 2) as bene_delta_per_unit,
CASE
WHEN payment_limit * 0.20 > 127.28 * 0.20 THEN 'bene saves'
WHEN payment_limit * 0.20 < 127.28 * 0.20 THEN 'bene pays more'
ELSE 'neutral'
END as bene_impact
FROM skin_subs.asp_quarterly
WHERE quarter = (SELECT max(quarter) FROM skin_subs.asp_quarterly)
ORDER BY bene_delta_per_unit
""")
bene_chart = (
alt.Chart(bene_impact.to_pandas())
.mark_bar()
.encode(
x=alt.X("bene_delta_per_unit:Q",
title="Change in Beneficiary Coinsurance per Unit ($)"),
y=alt.Y("short_description:N", title="", sort="x",
axis=alt.Axis(labelLimit=300)),
color=alt.Color("bene_impact:N",
scale=alt.Scale(
domain=["bene saves", "bene pays more", "neutral"],
range=["#2ca02c", "#d62728", "#7f7f7f"],
),
title="Impact"),
tooltip=["hcpcs_code", "short_description",
"current_bene_per_unit", "flat_bene_per_unit",
"bene_delta_per_unit"],
)
.properties(
title="CY2026 Beneficiary Coinsurance Change per Unit",
width=700,
)
)
bene_chart
return
# ── 6. Episode scenario comparison ───────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 6. Episode Scenarios: Before vs. After CY2026
Three wound scenarios showing the complete beneficiary cost
breakdown before and after the flat-rate reclassification.
All assume office setting (POS 11), participating provider,
national average application fee.
""")
return
@app.cell(hide_code=True)
def _(pl, q):
# Get average national app fee for 2025
avg_fee = q("""
SELECT round(avg(non_fac_fee), 2) as avg_fee
FROM pfs.carrier_locality
WHERE year = 2025 AND hcpcs = '15271'
""").item()
scenarios = pl.DataFrame({
"scenario": [
"Small wound (10cm²) — Q4101 Apligraf",
"Medium wound (25cm²) — Q4186 EpiFix",
"Large wound (50cm²) — Q4132 GrafixCore",
],
"units": [10, 25, 50],
"asp_per_unit": [30.23, 151.17, 106.70],
"product_name": ["Apligraf", "EpiFix", "GrafixCore"],
}).with_columns(
# Pre-2026: ASP + 6%
(pl.col("asp_per_unit") * pl.col("units") * 0.20).round(2).alias("pre_product_coins"),
pl.lit(avg_fee * 0.20).round(2).alias("pre_app_coins"),
# Post-2026: flat $127.28
(pl.lit(127.28) * pl.col("units") * 0.20).round(2).alias("post_product_coins"),
pl.lit(avg_fee * 0.20).round(2).alias("post_app_coins"),
).with_columns(
(pl.col("pre_product_coins") + pl.col("pre_app_coins")).alias("pre_total"),
(pl.col("post_product_coins") + pl.col("post_app_coins")).alias("post_total"),
).with_columns(
(pl.col("post_total") - pl.col("pre_total")).round(2).alias("delta"),
)
scenarios
return
# ── 7. Part B deductible context ─────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 7. Part B Deductible Context
Before coinsurance applies, beneficiaries must meet the annual
Part B deductible. A single skin substitute episode can exceed
the entire deductible, meaning the full coinsurance amount is
additional out-of-pocket cost for most beneficiaries.
""")
return
@app.cell(hide_code=True)
def _(DEDUCTIBLES, alt, pl):
deductible_df = pl.DataFrame({
"year": list(DEDUCTIBLES.keys()),
"deductible": list(DEDUCTIBLES.values()),
})
ded_chart = (
alt.Chart(deductible_df.to_pandas())
.mark_bar(color="#ff7f0e")
.encode(
x=alt.X("year:O", title="Year"),
y=alt.Y("deductible:Q", title="Annual Part B Deductible ($)"),
tooltip=["year", "deductible"],
)
.properties(
title="Medicare Part B Annual Deductible",
width=700, height=250,
)
)
ded_chart
return
# ── 8. Key takeaways ─────────────────────────────────────────────────
@app.cell(hide_code=True)
def _(mo):
mo.md("""
## 8. Key Takeaways
1. **OPPS copay drops >85% in CY2026.** From $105–$366 per code
to $25.43 flat. This is the single largest beneficiary-facing
change in the reclassification.
2. **High-ASP product users save significantly.** For EpiFix
(Q4186, ~$151/unit), coinsurance per cm² drops from ~$30 to
~$25 — but for a 25cm² wound, the total product coinsurance
drops from ~$755 to ~$636 ($119 savings).
3. **Low-ASP product users pay more.** For Apligraf (Q4101,
~$30/unit), per-unit coinsurance rises from ~$6 to ~$25.
A 25cm² wound goes from ~$30 to ~$636 in product coinsurance.
4. **Application fee coinsurance is stable.** The PFS component
(~$27–$35 for 15271 depending on locality) is a small fraction
of total cost sharing and relatively stable over time.
5. **Deductible is a floor, not a ceiling.** The $257 Part B
deductible (2025–2026) is typically exceeded by a single skin
sub episode, so coinsurance applies to the full amount.
6. **No Medigap/supplement analysis.** Most beneficiaries have
supplemental coverage (Medigap, employer, Medicaid dual) that
covers the 20% coinsurance. Actual out-of-pocket may be lower
depending on coverage type.
""")
return
if __name__ == "__main__":
app.run()