Files
stack/notebooks/skin_sub_cost_sharing.py
kert 71861d9d32
All checks were successful
CI / lint (push) Successful in 29s
CI / notebooks-smoke (push) Successful in 1m25s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 56s
Infra CI / zotero (push) Successful in 13s
Infra CI / docs (push) Successful in 1m14s
Infra CI / api (push) Successful in 50s
Infra CI / mc (push) Successful in 19s
Deploy / report (push) Successful in 13s
CI / test (push) Successful in 14m27s
Harden / build-scan-report (push) Successful in 26m15s
Renovate / renovate (push) Successful in 15s
Notebooks Integration / notebooks-integration (push) Successful in 7m16s
Zotero Sync / zotero-sync (push) Successful in 53s
Package Supply Chain / pkg-supply-chain (push) Successful in 58s
fix(lake): M5 covered OPPS only — build out PFS (refs #514)
The M5 close-out missed half the issue's scope: #514 says 'OPPS/PFS
reference data' and I cut over only OPPS, leaving PFS — the largest
reference domain, 23.5M rows across 8 tables — entirely on the
monolith, including pfs.* queries in the very notebook whose OPPS
query was migrated. This completes PFS the same way:

- publish_opps_to_lake.py → publish_reference_to_lake.py with a
  schema registry (opps: 3 tables, pfs: 8); host-side docker-exec
  wrapper extracted to dev/scripts/_lake.py, shared by the ingests.
- PFS published to the lake and read-back verified: carrier_locality
  21,863,770 rows in 10.1s, plus rvu/gpci/clinical_labor/medical_
  equipment/medical_supply/physician_work_time/zip_carrier_locality.
- New dev/scripts/ingest_pfs.py wraps pfs.pipe.load_all (previously
  ad-hoc, no entrypoint) with the standard plumbing: duckdb_batch
  preflight, replica refresh, lake publish.
- 5 notebooks migrated: pfs_calcs, pfs_reconciliation,
  skin_sub_budget_neutrality read the lake as their primary
  connection; skin_sub_pricing and skin_sub_cost_sharing switch their
  pure-pfs cells to the lake. The one cross-source join
  (pfs × skin_subs) stays on the monolith mirror, annotated.
  All 5 headless-verified in prod: zero cell errors.
- pfs_calcs leaves the pre-commit host-run safe list (the lake catalog
  is compose-internal); the nightly integration covers it in-container.
2026-07-10 23:47:51 -04:00

582 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (1527115278)
- **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()
# OPPS reference data lives in the DuckLake lakehouse (M5, #514);
# the monolith's opps schema is a deprecated mirror.
lake = connect.ducklake()
def q(sql):
return con.execute(sql).pl()
def ql(sql):
"""Query the lake (OPPS reference tables)."""
return lake.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, lake, pl, q, ql
# ── 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, ql):
regions = [
"MANHATTAN",
"REST OF FLORIDA",
"REST OF TEXAS",
"REST OF CALIFORNIA",
"SOUTH CAROLINA",
]
region_list = ", ".join(f"'{r}'" for r in regions)
app_coinsurance = ql(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):
# Cross-source join (skin_subs lives in the monolith, pfs in the
# lake) — reads the monolith's deprecated pfs mirror until skin_subs
# moves to the lake too.
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:
- **20212025**: 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, ql):
opps_copay = ql("""
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, ql):
# Get average national app fee for 2025
avg_fee = ql("""
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 (20252026) 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()