Merge P37: CY2027 PFS proposed rule — capture, ingest, APM analysis
This commit is contained in:
@@ -1,25 +1,16 @@
|
||||
"""Fetch + register the CY2026 PFS NPRM (proposed rule) addenda.
|
||||
"""Fetch + register PFS NPRM (proposed rule) addenda, by rule year.
|
||||
|
||||
Downloads the CMS-1832-P "CY 2026 PFS Proposed Rule Addenda" ZIP, which
|
||||
contains Addendum B (proposed RVUs by HCPCS), and optionally registers
|
||||
Addendum B in bib against the CY2026 PFS NPRM rule item (2KVJ2HKX),
|
||||
tagged ``sup:2026_PFS_NPRM`` so Task 5's loader can discover the
|
||||
*proposed* Addendum B without touching final-rule files (which carry
|
||||
``sup:2026_PFS_FR`` — the two namespaces must never collide).
|
||||
|
||||
Source page (CMS-1832-P detail page — the canonical
|
||||
``.../pfs-federal-regulation-notices/cms-1832-p`` URL 404s; this is the
|
||||
current path found via the federal-regulation-notices listing):
|
||||
https://www.cms.gov/medicare/payment/fee-schedules/physician/federal-regulation-notices/cms-1832-p
|
||||
Download link text on that page: "CY 2026 PFS Proposed Rule Addenda -
|
||||
Updated 07/29/2025" (the URL path segment literally contains
|
||||
"07/29/2025" for the update-date suffix — that's CMS's own href, not a
|
||||
typo here).
|
||||
Downloads a CY{year} PFS Proposed Rule "Addenda" ZIP, which contains
|
||||
Addendum B (proposed RVUs by HCPCS), and optionally registers Addendum B
|
||||
in bib against that year's NPRM rule item, tagged ``sup:{year}_PFS_NPRM``
|
||||
so the loader can discover the *proposed* Addendum B without touching
|
||||
final-rule files (which carry ``sup:{year}_PFS_FR`` — the two namespaces
|
||||
must never collide).
|
||||
|
||||
Usage:
|
||||
uv run python dev/scripts/fetch_nprm_addenda.py --dry-run
|
||||
uv run python dev/scripts/fetch_nprm_addenda.py
|
||||
uv run python dev/scripts/fetch_nprm_addenda.py --register
|
||||
uv run python dev/scripts/fetch_nprm_addenda.py --year 2027
|
||||
uv run python dev/scripts/fetch_nprm_addenda.py --year 2027 --register
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,36 +18,87 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import re
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DEST_DIR = ROOT / "data" / "cms" / "pfs_nprm" / "2026"
|
||||
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# CY2026 PFS Proposed Rule (CMS-1832-P) Addenda ZIP — resolved from the
|
||||
# CMS-1832-P detail page:
|
||||
# https://www.cms.gov/medicare/payment/fee-schedules/physician/federal-regulation-notices/cms-1832-p
|
||||
ADDENDA_URL = (
|
||||
"https://www.cms.gov/files/zip/"
|
||||
"cy-2026-pfs-proposed-rule-addenda-updated-07/29/2025.zip"
|
||||
)
|
||||
ADDENDA_ZIP_NAME = "cy2026_pfs_nprm_addenda.zip"
|
||||
|
||||
ADDENDUM_B_RE = re.compile(r"(?i)addendum[_ ]?b")
|
||||
|
||||
RULE_ITEM_KEY = "2KVJ2HKX"
|
||||
ADDENDUM_B_TITLE = "CY2026 PFS NPRM Addendum B (proposed RVUs)"
|
||||
REGISTER_TAGS = ["sup:2026_PFS_NPRM", "module:pfs", "file:rvu", "year:2026"]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class YearConfig:
|
||||
"""Per-rule-year config: where to fetch the addenda ZIP and how to
|
||||
register Addendum B against the corresponding bib rule item."""
|
||||
|
||||
year: int
|
||||
detail_page_url: str
|
||||
addenda_url: str
|
||||
dest_dir: Path
|
||||
addenda_zip_name: str
|
||||
rule_item_key: str
|
||||
addendum_b_title: str
|
||||
register_tags: list[str]
|
||||
|
||||
|
||||
def download_zip(dest: Path, *, retries: int = 3) -> bool:
|
||||
CONFIGS: dict[int, YearConfig] = {
|
||||
2026: YearConfig(
|
||||
year=2026,
|
||||
detail_page_url=(
|
||||
"https://www.cms.gov/medicare/payment/fee-schedules/physician/"
|
||||
"federal-regulation-notices/cms-1832-p"
|
||||
),
|
||||
# CY2026 PFS Proposed Rule (CMS-1832-P) Addenda ZIP — resolved from
|
||||
# the CMS-1832-P detail page above (the canonical
|
||||
# ".../pfs-federal-regulation-notices/cms-1832-p" URL 404s; this is
|
||||
# the current path found via the federal-regulation-notices
|
||||
# listing). Download link text on that page: "CY 2026 PFS Proposed
|
||||
# Rule Addenda - Updated 07/29/2025" (the URL path segment
|
||||
# literally contains "07/29/2025" for the update-date suffix —
|
||||
# that's CMS's own href, not a typo here).
|
||||
addenda_url=(
|
||||
"https://www.cms.gov/files/zip/"
|
||||
"cy-2026-pfs-proposed-rule-addenda-updated-07/29/2025.zip"
|
||||
),
|
||||
dest_dir=ROOT / "data" / "cms" / "pfs_nprm" / "2026",
|
||||
addenda_zip_name="cy2026_pfs_nprm_addenda.zip",
|
||||
rule_item_key="2KVJ2HKX",
|
||||
addendum_b_title="CY2026 PFS NPRM Addendum B (proposed RVUs)",
|
||||
register_tags=["sup:2026_PFS_NPRM", "module:pfs", "file:rvu", "year:2026"],
|
||||
),
|
||||
2027: YearConfig(
|
||||
year=2027,
|
||||
detail_page_url=(
|
||||
"https://www.cms.gov/medicare/payment/fee-schedules/physician/"
|
||||
"federal-regulation-notices/cms-1848-p"
|
||||
),
|
||||
# CY2027 PFS Proposed Rule (CMS-1848-P) Addenda ZIP — resolved from
|
||||
# the CMS-1848-P detail page above (fallback if that 404s: the
|
||||
# federal-regulation-notices index page). Download link text on
|
||||
# that page: "CY 2027 PFS Proposed Rule Addenda (Updated
|
||||
# 07/21/2026)".
|
||||
addenda_url=(
|
||||
"https://www.cms.gov/files/zip/"
|
||||
"cy-2027-pfs-proposed-rule-addenda-updated-07-21-2026.zip"
|
||||
),
|
||||
dest_dir=ROOT / "data" / "cms" / "pfs_nprm" / "2027",
|
||||
addenda_zip_name="cy2027_pfs_nprm_addenda.zip",
|
||||
rule_item_key="5ITGVDJV",
|
||||
addendum_b_title="CY2027 PFS NPRM Addendum B (proposed RVUs)",
|
||||
register_tags=["sup:2027_PFS_NPRM", "module:pfs", "file:rvu", "year:2027"],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def download_zip(url: str, dest: Path, *, retries: int = 3) -> bool:
|
||||
"""Download the addenda ZIP if it doesn't already exist."""
|
||||
import time
|
||||
|
||||
@@ -67,7 +109,7 @@ def download_zip(dest: Path, *, retries: int = 3) -> bool:
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
resp = httpx.get(
|
||||
ADDENDA_URL,
|
||||
url,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=120,
|
||||
follow_redirects=True,
|
||||
@@ -96,11 +138,20 @@ def extract_zip(zip_path: Path, dest_dir: Path) -> list[Path]:
|
||||
|
||||
|
||||
def find_addendum_b(files: list[Path]) -> Path | None:
|
||||
"""Find the extracted file whose name matches Addendum B."""
|
||||
"""Find the extracted file whose name matches Addendum B.
|
||||
|
||||
Excludes macOS ``__MACOSX/._*`` resource-fork junk that CMS's zips
|
||||
sometimes carry alongside the real file — those AppleDouble files
|
||||
match the same name pattern and suffix, so they'd otherwise be
|
||||
eligible candidates too.
|
||||
"""
|
||||
candidates = [
|
||||
f
|
||||
for f in files
|
||||
if ADDENDUM_B_RE.search(f.name) and f.suffix.lower() in (".xlsx", ".csv")
|
||||
if ADDENDUM_B_RE.search(f.name)
|
||||
and f.suffix.lower() in (".xlsx", ".csv")
|
||||
and not f.name.startswith("._")
|
||||
and "__MACOSX" not in f.parts
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
@@ -134,7 +185,7 @@ def _existing_attachment(store: Any, item_key: str, filename: str) -> str | None
|
||||
return row["key"] if row else None
|
||||
|
||||
|
||||
def register(addendum_b_path: Path) -> None:
|
||||
def register(cfg: YearConfig, addendum_b_path: Path) -> None:
|
||||
"""Attach Addendum B to the NPRM rule item and tag it.
|
||||
|
||||
Idempotent: if an attachment with the Addendum B title already exists
|
||||
@@ -147,15 +198,15 @@ def register(addendum_b_path: Path) -> None:
|
||||
|
||||
store = connect.bib()
|
||||
|
||||
existing_key = _existing_attachment(store, RULE_ITEM_KEY, ADDENDUM_B_TITLE)
|
||||
existing_key = _existing_attachment(store, cfg.rule_item_key, cfg.addendum_b_title)
|
||||
if existing_key is not None:
|
||||
print(
|
||||
f"SKIP attach — {RULE_ITEM_KEY} already has an attachment titled "
|
||||
f"{ADDENDUM_B_TITLE!r} (key {existing_key})"
|
||||
f"SKIP attach — {cfg.rule_item_key} already has an attachment "
|
||||
f"titled {cfg.addendum_b_title!r} (key {existing_key})"
|
||||
)
|
||||
else:
|
||||
att_key = store.attach_file(
|
||||
RULE_ITEM_KEY, addendum_b_path, title=ADDENDUM_B_TITLE
|
||||
cfg.rule_item_key, addendum_b_path, title=cfg.addendum_b_title
|
||||
)
|
||||
con = store._con() # noqa: SLF001 — same pattern as bib/oig.py, bib/iom.py
|
||||
row = con.execute(
|
||||
@@ -164,16 +215,30 @@ def register(addendum_b_path: Path) -> None:
|
||||
storage_path = row["storage_path"] if row else "<unknown>"
|
||||
print(f"Attached {addendum_b_path.name} (key {att_key}) -> {storage_path}")
|
||||
|
||||
for tag in REGISTER_TAGS:
|
||||
store.add_tag(RULE_ITEM_KEY, tag)
|
||||
print(f"Tagged {RULE_ITEM_KEY} with: {', '.join(REGISTER_TAGS)}")
|
||||
item = store.get(RULE_ITEM_KEY)
|
||||
print(f"Current tags on {RULE_ITEM_KEY}: {item.tags}")
|
||||
for tag in cfg.register_tags:
|
||||
store.add_tag(cfg.rule_item_key, tag)
|
||||
print(f"Tagged {cfg.rule_item_key} with: {', '.join(cfg.register_tags)}")
|
||||
item = store.get(cfg.rule_item_key)
|
||||
print(f"Current tags on {cfg.rule_item_key}: {item.tags}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
years = sorted(CONFIGS)
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch + register the CY2026 PFS NPRM addenda"
|
||||
description=(
|
||||
"Fetch + register PFS NPRM addenda for a given rule year "
|
||||
f"(available: {', '.join(str(y) for y in years)})."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--year",
|
||||
type=int,
|
||||
choices=years,
|
||||
default=2026,
|
||||
help=(
|
||||
"Rule year to fetch — one of "
|
||||
f"{', '.join(str(y) for y in years)} (default: 2026)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
@@ -183,34 +248,35 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--register",
|
||||
action="store_true",
|
||||
help="After fetching, attach Addendum B to bib item "
|
||||
f"{RULE_ITEM_KEY} and apply the {', '.join(REGISTER_TAGS)} tags.",
|
||||
help="After fetching, attach Addendum B to the rule item and apply "
|
||||
"its tags for the selected --year.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
cfg = CONFIGS[args.year]
|
||||
|
||||
zip_dest = DEST_DIR / ADDENDA_ZIP_NAME
|
||||
zip_dest = cfg.dest_dir / cfg.addenda_zip_name
|
||||
|
||||
if args.dry_run:
|
||||
print(f"Would GET {ADDENDA_URL}")
|
||||
print(f"Would GET {cfg.addenda_url}")
|
||||
print(f"Would save {zip_dest}")
|
||||
print(f"Would extract into {DEST_DIR}")
|
||||
print(f"Would extract into {cfg.dest_dir}")
|
||||
if args.register:
|
||||
print(
|
||||
f"Would attach Addendum B to {RULE_ITEM_KEY} "
|
||||
f"and tag with: {', '.join(REGISTER_TAGS)}"
|
||||
f"Would attach Addendum B to {cfg.rule_item_key} "
|
||||
f"and tag with: {', '.join(cfg.register_tags)}"
|
||||
)
|
||||
return
|
||||
|
||||
DEST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cfg.dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not download_zip(zip_dest):
|
||||
raise SystemExit(f"Failed to download {ADDENDA_URL}")
|
||||
if not download_zip(cfg.addenda_url, zip_dest):
|
||||
raise SystemExit(f"Failed to download {cfg.addenda_url}")
|
||||
|
||||
files = extract_zip(zip_dest, DEST_DIR)
|
||||
print(f"\nExtracted {len(files)} files to {DEST_DIR}:")
|
||||
files = extract_zip(zip_dest, cfg.dest_dir)
|
||||
print(f"\nExtracted {len(files)} files to {cfg.dest_dir}:")
|
||||
for f in files:
|
||||
size_kb = f.stat().st_size / 1024
|
||||
print(f" {f.relative_to(DEST_DIR)} ({size_kb:.0f} KB)")
|
||||
print(f" {f.relative_to(cfg.dest_dir)} ({size_kb:.0f} KB)")
|
||||
|
||||
addendum_b = find_addendum_b(files)
|
||||
if addendum_b is None:
|
||||
@@ -218,12 +284,12 @@ def main() -> None:
|
||||
"Could not find an Addendum B file (pattern "
|
||||
f"{ADDENDUM_B_RE.pattern!r}) among extracted files."
|
||||
)
|
||||
print(f"\nAddendum B: {addendum_b.relative_to(DEST_DIR)}")
|
||||
print(f"\nAddendum B: {addendum_b.relative_to(cfg.dest_dir)}")
|
||||
preview_addendum_b(addendum_b)
|
||||
|
||||
if args.register:
|
||||
print()
|
||||
register(addendum_b)
|
||||
register(cfg, addendum_b)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -36,14 +36,16 @@ def main() -> int:
|
||||
parser.add_argument(
|
||||
"--nprm",
|
||||
action="store_true",
|
||||
help="also load the NPRM Addendum B proposed RVUs into pfs.rvu_proposed",
|
||||
help=(
|
||||
"also load every registered NPRM Addendum B proposed-RVU "
|
||||
"partition (pfs.nprm.NPRM_SOURCES) into pfs.rvu_proposed"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
from cms import ingest_log
|
||||
from conf.connect import duckdb_batch, publish_replica
|
||||
from pfs.pipe import load_all
|
||||
from pfs.rules import RULES
|
||||
|
||||
run_id = ingest_log.new_run_id()
|
||||
|
||||
@@ -62,22 +64,23 @@ def main() -> int:
|
||||
)
|
||||
|
||||
if args.nprm:
|
||||
from pfs.nprm import load_rvu_proposed
|
||||
from pfs.nprm import NPRM_SOURCES, load_rvu_proposed
|
||||
|
||||
print("\n--- NPRM Addendum B (proposed RVUs) ---")
|
||||
nprm_out = load_rvu_proposed(con)
|
||||
print(f" pfs.rvu_proposed : {nprm_out}")
|
||||
ingest_log.log_ingest(
|
||||
con,
|
||||
module="pfs",
|
||||
table_name="pfs.rvu_proposed",
|
||||
rows=nprm_out["rows"],
|
||||
source_file=nprm_out["source_file"],
|
||||
rule_id="CMS-1832-P",
|
||||
fr_citation=RULES[2026].proposed.federal_register_citation,
|
||||
pincite_key="2KVJ2HKX",
|
||||
run_id=run_id,
|
||||
)
|
||||
for year, _tag, cms_rule_id, _pincite_key in NPRM_SOURCES:
|
||||
nprm_out = load_rvu_proposed(con, year=year)
|
||||
print(f" pfs.rvu_proposed [{cms_rule_id}]: {nprm_out}")
|
||||
ingest_log.log_ingest(
|
||||
con,
|
||||
module="pfs",
|
||||
table_name="pfs.rvu_proposed",
|
||||
rows=nprm_out["rows"],
|
||||
source_file=nprm_out["source_file"],
|
||||
rule_id=nprm_out["cms_rule_id"],
|
||||
fr_citation=nprm_out["fr_citation"],
|
||||
pincite_key=nprm_out["pincite_key"],
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
replica = publish_replica("aco")
|
||||
print(f"replica → {replica}")
|
||||
|
||||
53
dev/scripts/refarm_cms_2026_2377.sh
Executable file
53
dev/scripts/refarm_cms_2026_2377.sh
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# Post-comment-close re-farm for docket CMS-2026-2377 (CY2027 PFS NPRM,
|
||||
# CMS-1848-P). Scheduled via host crontab for 2026-09-15 (comment period
|
||||
# closes 2026-09-14 per 91 FR 43842 DATES block). Tracker: issue #615.
|
||||
#
|
||||
# Every step is resumable/incremental — safe to re-run on partial failure.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/../.."
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
set -a; source .env; set +a
|
||||
|
||||
mkdir -p .state/comments
|
||||
LOG=.state/comments/refarm-CMS-2026-2377.log
|
||||
exec >>"$LOG" 2>&1
|
||||
echo "=== re-farm run $(date -Is) ==="
|
||||
|
||||
FAILURES=0
|
||||
|
||||
uv run stack bib fetch-pfs-comments || { echo "WARN: fetch-pfs-comments rc=$?"; FAILURES=$((FAILURES+1)); }
|
||||
uv run stack bib backfill-comments || { echo "WARN: backfill rc=$?"; FAILURES=$((FAILURES+1)); }
|
||||
uv run stack comments extract --docket CMS-2026-2377 || { echo "WARN: extract rc=$?"; FAILURES=$((FAILURES+1)); }
|
||||
uv run stack comments extract-ocr --docket CMS-2026-2377 || { echo "WARN: ocr rc=$?"; FAILURES=$((FAILURES+1)); }
|
||||
uv run stack llm index --collection comments --docket CMS-2026-2377 \
|
||||
|| { echo "WARN: index rc=$?"; FAILURES=$((FAILURES+1)); }
|
||||
|
||||
COUNTS=$(uv run python - <<'EOF'
|
||||
from conf import connect
|
||||
s = connect.bib()
|
||||
con = s._con()
|
||||
total, enriched = con.execute(
|
||||
"SELECT count(*), sum(CASE WHEN coalesce(abstract,'')<>'' THEN 1 ELSE 0 END) "
|
||||
"FROM items WHERE url LIKE 'https://www.regulations.gov/comment/CMS-2026-2377-%'"
|
||||
).fetchone()
|
||||
print(f"comments={total} enriched={enriched}")
|
||||
s.close()
|
||||
EOF
|
||||
) || { echo "WARN: counts rc=$?"; FAILURES=$((FAILURES+1)); }
|
||||
echo "$COUNTS"
|
||||
|
||||
if [ "$FAILURES" -eq 0 ]; then
|
||||
RESULT_MSG="Automated post-close re-farm ran $(date -I): $COUNTS."
|
||||
else
|
||||
RESULT_MSG="Automated post-close re-farm ran $(date -I) — completed with $FAILURES failed steps — check log. $COUNTS."
|
||||
fi
|
||||
|
||||
curl -sf -X POST \
|
||||
"https://git.fhirworx.io/api/v1/repos/homelab/stack/issues/615/comments" \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "User-Agent: curl/8.5.0" \
|
||||
-d "{\"body\": \"$RESULT_MSG Log: .state/comments/refarm-CMS-2026-2377.log. Review counts, spot-check indexing, then close this issue and milestone P37.\n\nIf complete: close #615, close milestone P37, and REMOVE the crontab line (re-fires annually).\"}" \
|
||||
|| echo "WARN: gitea comment failed"
|
||||
echo "=== done $(date -Is) ==="
|
||||
@@ -278,8 +278,8 @@ def _(mo):
|
||||
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,
|
||||
SELECT hcpcs, description, work_rvu, non_fac_pe_rvu, mp_rvu,
|
||||
work_rvu + non_fac_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'
|
||||
@@ -295,9 +295,10 @@ def _(q):
|
||||
ELSE 'Other'
|
||||
END AS category
|
||||
FROM pfs.rvu_proposed
|
||||
WHERE (modifier IS NULL OR modifier = '')
|
||||
WHERE cms_rule_id = 'CMS-1832-P'
|
||||
AND (mod IS NULL OR mod = '')
|
||||
AND status_code = 'A'
|
||||
AND nonfac_pe_rvu IS NOT NULL
|
||||
AND non_fac_pe_rvu IS NOT NULL
|
||||
QUALIFY row_number() OVER (PARTITION BY hcpcs ORDER BY hcpcs) = 1
|
||||
),
|
||||
final_2025 AS (
|
||||
@@ -433,13 +434,13 @@ def _(mo, rvu_delta):
|
||||
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
|
||||
WHERE p.cms_rule_id = 'CMS-1832-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=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=''))
|
||||
AND NOT EXISTS (SELECT 1 FROM pfs.rvu_proposed p WHERE p.cms_rule_id = 'CMS-1832-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 `pfs.rvu_proposed`
|
||||
@@ -603,7 +604,7 @@ def _(QPP, pl):
|
||||
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")
|
||||
_df["status"] = _df["apm_incentive_pct"].apply(lambda v: "Paid" if v > 0 else "None under current law")
|
||||
|
||||
incentive_chart = (
|
||||
alt.Chart(_df)
|
||||
@@ -611,10 +612,14 @@ def _(alt, apm_thresholds, mo):
|
||||
.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"])),
|
||||
color=alt.Color(
|
||||
"status:N",
|
||||
title=None,
|
||||
scale=alt.Scale(domain=["Paid", "None under current law"]),
|
||||
),
|
||||
tooltip=["performance_year", "payment_year", "apm_incentive_pct", "status"],
|
||||
)
|
||||
.properties(title="APM Incentive Payment Sunset", width=600, height=300)
|
||||
.properties(title="APM Incentive Payment — Not a Clean Sunset", width=600, height=300)
|
||||
)
|
||||
|
||||
_citations = ", ".join(sorted(set(_df["citation"])))
|
||||
@@ -623,12 +628,18 @@ def _(alt, apm_thresholds, mo):
|
||||
[
|
||||
incentive_chart,
|
||||
mo.md(f"""
|
||||
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.
|
||||
The lump-sum APM Incentive Payment does **not** sunset
|
||||
cleanly after payment year 2026. Payment year 2027 (QP
|
||||
Performance Period 2025) 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** (`QPP[2026]`) — CMS-1848-P proposes to
|
||||
codify that revival into 42 CFR 414.1450(b)(1) (91 FR
|
||||
44218-44219, 44286), superseding the CY2026 Final Rule's
|
||||
then-accurate "no incentive after 2026" narrative reflected
|
||||
in the disposition table below. So the pattern across
|
||||
payment years 2025-2028 is paid / paid / gap / revived, not
|
||||
a single cutoff.
|
||||
|
||||
**Sources:** `qpp.QPP` performance years 2023–2026
|
||||
({_citations}).
|
||||
@@ -642,8 +653,21 @@ def _(alt, apm_thresholds, mo):
|
||||
def _(QPP, mo):
|
||||
_cy = QPP[2026]
|
||||
_proposed = _cy.proposed
|
||||
_qp = _cy.qp_thresholds
|
||||
_partial = _cy.partial_qp_thresholds
|
||||
|
||||
# As of the CY2026 PFS Final Rule (90 FR 49980), the payment-year-2028
|
||||
# QP/Partial QP thresholds were understood to be 75%/50% (QP) and
|
||||
# 50%/35% (Partial QP) -- CMS-1832-P proposed, and the Final Rule
|
||||
# finalized, "no numeric change" from those then-current-law figures.
|
||||
# These two literals are a CITED TRANSCRIPTION of that now-superseded
|
||||
# disposition, not a live read of `QPP[2026].qp_thresholds` /
|
||||
# `partial_qp_thresholds` -- those fields were corrected under task
|
||||
# #620 (CAA 2026 legislatively restored 50%/35% QP, 40%/25% Partial
|
||||
# QP for payment year 2028; see the `qpp` module docstring "CAA 2026
|
||||
# supersession"). Pinning the historical values here rather than
|
||||
# reading the registry live preserves what CMS actually said in this
|
||||
# disposition table instead of retroactively rewriting it.
|
||||
_qp_then = "75%/50%"
|
||||
_partial_then = "50%/35%"
|
||||
mo.md(f"""
|
||||
### CY2026 NPRM proposed QPP changes — proposed vs. finalized disposition
|
||||
|
||||
@@ -658,7 +682,7 @@ def _(QPP, mo):
|
||||
| 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 `qp_thresholds` {_qp.payment_amount_pct:.0f}%/{_qp.patient_count_pct:.0f}% (QP), `partial_qp_thresholds` {_partial.payment_amount_pct:.0f}%/{_partial.patient_count_pct:.0f}% (Partial QP) |
|
||||
| QP / Partial QP percentage thresholds | **No numeric change proposed** — remained `qp_thresholds` {_qp_then} (QP), `partial_qp_thresholds` {_partial_then} (Partial QP) as understood at CY2026 Final Rule publication (90 FR 49980) — **CITED TRANSCRIPTION, since SUPERSEDED for payment year 2028 by CAA 2026; see `QPP[2026].qp_thresholds`/`partial_qp_thresholds` for current law** |
|
||||
| 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`.
|
||||
|
||||
779
notebooks/cy2027_pfs_proposed_rule.py
Normal file
779
notebooks/cy2027_pfs_proposed_rule.py
Normal file
@@ -0,0 +1,779 @@
|
||||
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()
|
||||
130
src/pfs/nprm.py
130
src/pfs/nprm.py
@@ -1,13 +1,18 @@
|
||||
"""NPRM Addendum B loader → ``pfs.rvu_proposed`` (#592).
|
||||
"""NPRM Addendum B loader → ``pfs.rvu_proposed`` (#592, #612).
|
||||
|
||||
Loads the CY2026 NPRM Addendum B "Relative Value Units and Related
|
||||
Information" workbook — registered in bib under the
|
||||
``sup:2026_PFS_NPRM`` tag — into ``pfs.rvu_proposed``, the
|
||||
Loads a PFS NPRM's Addendum B "Relative Value Units and Related
|
||||
Information" workbook — registered in bib under a per-year
|
||||
``sup:<year>_PFS_NPRM`` tag — into ``pfs.rvu_proposed``, the
|
||||
proposed-rule counterpart to the final-rule ``pfs.rvu`` table that
|
||||
``pfs.pipe.load_all`` populates. Reuses ``pfs.pipe``'s header-detect
|
||||
readers and column map instead of reimplementing them; the target
|
||||
schema is intentionally narrower than ``pfs.rvu`` (no PPRRVU-only
|
||||
columns) since Addendum B never carries them.
|
||||
|
||||
``pfs.rvu_proposed`` holds one partition per ``cms_rule_id`` — CY2026's
|
||||
NPRM (CMS-1832-P) and CY2027's NPRM (CMS-1848-P) load side by side, and
|
||||
``load_rvu_proposed`` only ever deletes/reinserts the partition for the
|
||||
year it was called with (see ``NPRM_SOURCES`` below).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,29 +21,23 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_ADDENDUM_B_TAG = "sup:2026_PFS_NPRM"
|
||||
_ADDENDUM_B_RE = re.compile(r"(?i)addendum[_ ]?b")
|
||||
|
||||
# pfs.pipe._RVU_COLUMNS normalizes raw headers to these intermediate
|
||||
# names; map them onto the pfs.rvu_proposed target schema.
|
||||
_TARGET_RENAME = {
|
||||
"hcpcs": "hcpcs",
|
||||
"mod": "modifier",
|
||||
"description": "description",
|
||||
"status_code": "status_code",
|
||||
"work_rvu": "work_rvu",
|
||||
"non_fac_pe_rvu": "nonfac_pe_rvu",
|
||||
"fac_pe_rvu": "fac_pe_rvu",
|
||||
"mp_rvu": "mp_rvu",
|
||||
}
|
||||
|
||||
# names, which already match the pfs.rvu_proposed target schema (#607
|
||||
# aligned it with pfs.rvu's column names — no renaming needed here).
|
||||
# The CY2027 Addendum B workbook's headers (CPT1/\nHCPCS, Mod, Status,
|
||||
# \nWork\nRVUs2, \nNon-\nFacility\nPE\nRVUs2, \nFacility\nPE\nRVUs2,
|
||||
# Mal-\nPractice\nRVUs2) are already present in _RVU_COLUMNS as
|
||||
# variants of the CY2026 PPRRVU headers, so no map changes were needed
|
||||
# for #612 — verified against the real CY2027 file.
|
||||
_TARGET_COLUMNS = [
|
||||
"hcpcs",
|
||||
"modifier",
|
||||
"mod",
|
||||
"description",
|
||||
"status_code",
|
||||
"work_rvu",
|
||||
"nonfac_pe_rvu",
|
||||
"non_fac_pe_rvu",
|
||||
"fac_pe_rvu",
|
||||
"mp_rvu",
|
||||
"cms_rule_id",
|
||||
@@ -47,13 +46,31 @@ _TARGET_COLUMNS = [
|
||||
# Intermediate (pre-rename) names as produced by pfs.pipe._normalize_columns.
|
||||
_RAW_NUMERIC_COLUMNS = ("work_rvu", "non_fac_pe_rvu", "fac_pe_rvu", "mp_rvu")
|
||||
|
||||
# (year, sup_tag, cms_rule_id, pincite_key) — one entry per NPRM Addendum
|
||||
# B partition. Add a row here (plus a pincite/proposed_for entry, see
|
||||
# pfs.rules) to onboard a new year; load_rvu_proposed(year=...) and
|
||||
# dev/scripts/ingest_pfs.py --nprm both iterate this tuple.
|
||||
NPRM_SOURCES: tuple[tuple[int, str, str, str], ...] = (
|
||||
(2026, "sup:2026_PFS_NPRM", "CMS-1832-P", "2KVJ2HKX"),
|
||||
(2027, "sup:2027_PFS_NPRM", "CMS-1848-P", "5ITGVDJV"),
|
||||
)
|
||||
|
||||
def _discover_addendum_b() -> Path:
|
||||
|
||||
def _source_for(year: int) -> tuple[int, str, str, str]:
|
||||
"""Look up ``year``'s ``NPRM_SOURCES`` entry, or raise ``ValueError``."""
|
||||
for source in NPRM_SOURCES:
|
||||
if source[0] == year:
|
||||
return source
|
||||
known = ", ".join(str(s[0]) for s in NPRM_SOURCES)
|
||||
raise ValueError(f"No NPRM source registered for year {year} (known: {known})")
|
||||
|
||||
|
||||
def _discover_addendum_b(tag: str) -> Path:
|
||||
"""Locate the NPRM Addendum B attachment in the bib store.
|
||||
|
||||
Queries attachments of items tagged ``sup:2026_PFS_NPRM`` for one
|
||||
whose filename matches ``addendum[_ ]?b`` (case-insensitive),
|
||||
mirroring the join in ``llm/source.py::_attachment_text``.
|
||||
Queries attachments of items tagged ``tag`` for one whose filename
|
||||
matches ``addendum[_ ]?b`` (case-insensitive), mirroring the join
|
||||
in ``llm/source.py::_attachment_text``.
|
||||
"""
|
||||
from conf import connect
|
||||
|
||||
@@ -67,7 +84,7 @@ def _discover_addendum_b() -> Path:
|
||||
"JOIN tags t ON t.id = it.tag_id "
|
||||
"WHERE t.name = ? "
|
||||
"ORDER BY a.id",
|
||||
(_ADDENDUM_B_TAG,),
|
||||
(tag,),
|
||||
)
|
||||
.fetchall()
|
||||
)
|
||||
@@ -76,17 +93,43 @@ def _discover_addendum_b() -> Path:
|
||||
if _ADDENDUM_B_RE.search(filename):
|
||||
return Path(row["storage_path"])
|
||||
raise FileNotFoundError(
|
||||
f"No Addendum B attachment found among bib items tagged {_ADDENDUM_B_TAG!r}"
|
||||
f"No Addendum B attachment found among bib items tagged {tag!r}"
|
||||
)
|
||||
|
||||
|
||||
def load_rvu_proposed(con: Any, *, cms_rule_id: str = "CMS-1832-P") -> dict:
|
||||
"""Load the NPRM Addendum B workbook into ``pfs.rvu_proposed``.
|
||||
def _schema_matches_target(con: Any) -> bool:
|
||||
"""True if ``pfs.rvu_proposed`` doesn't exist yet, or its columns match.
|
||||
|
||||
Idempotent: deletes any existing rows for ``cms_rule_id`` before
|
||||
inserting, so re-running the ingest is safe.
|
||||
False means a stale table (e.g. the pre-#612 ``modifier`` /
|
||||
``nonfac_pe_rvu`` column names) needs dropping before the
|
||||
``CREATE TABLE IF NOT EXISTS`` below — that statement is a no-op
|
||||
against an existing table, so it can't fix a column mismatch on
|
||||
its own.
|
||||
"""
|
||||
rows = con.execute(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = 'pfs' AND table_name = 'rvu_proposed' "
|
||||
"ORDER BY ordinal_position"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return True
|
||||
existing = [r[0] for r in rows]
|
||||
return existing == _TARGET_COLUMNS
|
||||
|
||||
Returns ``{"rows": int, "source_file": str}``.
|
||||
|
||||
def load_rvu_proposed(con: Any, *, year: int) -> dict:
|
||||
"""Load ``year``'s NPRM Addendum B workbook into ``pfs.rvu_proposed``.
|
||||
|
||||
Resolves the bib tag, ``cms_rule_id``, and pincite key from
|
||||
``NPRM_SOURCES``, and the Federal Register citation from
|
||||
``pfs.rules.proposed_for(year)``.
|
||||
|
||||
Idempotent: deletes any existing rows for that year's
|
||||
``cms_rule_id`` before inserting, so re-running the ingest is safe
|
||||
and leaves other years' partitions untouched.
|
||||
|
||||
Returns ``{"rows": int, "source_file": str, "cms_rule_id": str,
|
||||
"fr_citation": str, "pincite_key": str}``.
|
||||
"""
|
||||
import polars as pl
|
||||
|
||||
@@ -96,8 +139,13 @@ def load_rvu_proposed(con: Any, *, cms_rule_id: str = "CMS-1832-P") -> dict:
|
||||
_read_csv_with_header,
|
||||
_read_excel,
|
||||
)
|
||||
from pfs.rules import proposed_for
|
||||
|
||||
path = _discover_addendum_b()
|
||||
_, tag, cms_rule_id, pincite_key = _source_for(year)
|
||||
rule = proposed_for(year)
|
||||
fr_citation = rule.federal_register_citation if rule is not None else ""
|
||||
|
||||
path = _discover_addendum_b(tag)
|
||||
ext = path.suffix.lower()
|
||||
if ext == ".csv":
|
||||
df = _read_csv_with_header(str(path))
|
||||
@@ -138,20 +186,22 @@ def load_rvu_proposed(con: Any, *, cms_rule_id: str = "CMS-1832-P") -> dict:
|
||||
& pl.col("hcpcs").str.contains(r"^[A-Za-z0-9]{5}$")
|
||||
)
|
||||
|
||||
df = df.select(list(_TARGET_RENAME)).rename(_TARGET_RENAME)
|
||||
df = df.select(_TARGET_COLUMNS[:-1]) # all but cms_rule_id, added next
|
||||
df = df.with_columns(pl.lit(cms_rule_id).alias("cms_rule_id"))
|
||||
df = df.select(_TARGET_COLUMNS)
|
||||
|
||||
con.execute("CREATE SCHEMA IF NOT EXISTS pfs")
|
||||
if not _schema_matches_target(con):
|
||||
con.execute("DROP TABLE IF EXISTS pfs.rvu_proposed")
|
||||
con.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pfs.rvu_proposed (
|
||||
hcpcs VARCHAR,
|
||||
modifier VARCHAR,
|
||||
mod VARCHAR,
|
||||
description VARCHAR,
|
||||
status_code VARCHAR,
|
||||
work_rvu DOUBLE,
|
||||
nonfac_pe_rvu DOUBLE,
|
||||
non_fac_pe_rvu DOUBLE,
|
||||
fac_pe_rvu DOUBLE,
|
||||
mp_rvu DOUBLE,
|
||||
cms_rule_id VARCHAR
|
||||
@@ -163,10 +213,16 @@ def load_rvu_proposed(con: Any, *, cms_rule_id: str = "CMS-1832-P") -> dict:
|
||||
try:
|
||||
con.execute(
|
||||
"INSERT INTO pfs.rvu_proposed SELECT "
|
||||
"hcpcs, modifier, description, status_code, work_rvu, "
|
||||
"nonfac_pe_rvu, fac_pe_rvu, mp_rvu, cms_rule_id FROM _nprm_df"
|
||||
"hcpcs, mod, description, status_code, work_rvu, "
|
||||
"non_fac_pe_rvu, fac_pe_rvu, mp_rvu, cms_rule_id FROM _nprm_df"
|
||||
)
|
||||
finally:
|
||||
con.unregister("_nprm_df")
|
||||
|
||||
return {"rows": df.height, "source_file": str(path)}
|
||||
return {
|
||||
"rows": df.height,
|
||||
"source_file": str(path),
|
||||
"cms_rule_id": cms_rule_id,
|
||||
"fr_citation": fr_citation,
|
||||
"pincite_key": pincite_key,
|
||||
}
|
||||
|
||||
@@ -35,6 +35,31 @@ Usage::
|
||||
RULES[2024].conversion_factor # $33.2875 (dominant)
|
||||
RULES[2024].cf_for_date(date(2024, 2, 1)) # $32.7442 (pre-CAA)
|
||||
RULES[2024].cf_for_date(date(2024, 6, 1)) # $33.2875 (post-CAA)
|
||||
|
||||
Proposed (NPRM) parameters — two homes, one accessor
|
||||
------------------------------------------------------
|
||||
|
||||
CMS's NPRM (proposed rule) precedes the Final Rule by months, and for
|
||||
a stretch of time a year may have only the former. This module has
|
||||
two places NPRM parameters can live, and a helper that hides which
|
||||
one applies:
|
||||
|
||||
* ``RULES[year].proposed`` — once a year has a Final Rule entry in
|
||||
``RULES``, its ``ProposedRule`` (if captured) hangs off that entry
|
||||
(P36 shape; see e.g. ``RULES[2026].proposed``).
|
||||
* ``PROPOSED[year]`` — a module-level registry for years whose NPRM
|
||||
has been transcribed but which have **no** Final Rule yet, so there
|
||||
is no ``RULES[year]`` to hang it on (e.g. CY2027 as of this
|
||||
writing — see ``PROPOSED[2027]`` below).
|
||||
|
||||
Use ``proposed_for(year)`` rather than reaching into either dict
|
||||
directly — it returns ``RULES[year].proposed`` when ``year`` is in
|
||||
``RULES``, else ``PROPOSED.get(year)``, else ``None``. This is a
|
||||
migration path: when CY2027's Final Rule eventually ships,
|
||||
``RULES[2027]`` gets created with its own ``proposed=...`` block and
|
||||
the ``PROPOSED[2027]`` entry is retired — ``proposed_for(2027)``
|
||||
keeps returning the right thing across that move without callers
|
||||
changing. ``RuleYear`` itself needs no change for this to work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -452,6 +477,95 @@ RULES: dict[int, RuleYear] = {
|
||||
}
|
||||
|
||||
|
||||
# ── Proposed-only registry (years with no Final Rule yet) ────────
|
||||
#
|
||||
# `RuleYear.proposed` (above) is the home for NPRM parameters once a
|
||||
# year has a Final Rule to hang them on (P36 shape, e.g. RULES[2026]).
|
||||
# CY2027's NPRM (CMS-1848-P) has no Final Rule yet, so RULES has no
|
||||
# 2027 entry — `PROPOSED` holds `ProposedRule` values for such
|
||||
# final-less years. When CY2027's Final Rule ships, `RULES[2027]` is
|
||||
# created with `proposed=PROPOSED[2027]` (or an equivalent
|
||||
# re-transcription) and the `PROPOSED[2027]` entry is retired —
|
||||
# callers that used `proposed_for(2027)` see no change since the
|
||||
# helper checks `RULES` first.
|
||||
|
||||
PROPOSED: dict[int, ProposedRule] = {
|
||||
2027: ProposedRule(
|
||||
# CY2027 PFS NPRM (CMS-1848-P), 91 FR 43842, published
|
||||
# 2026-07-16, comment period closes 2026-09-14 per the DATES
|
||||
# block. Transcribed from data/fr_downloads/2026-14327.txt
|
||||
# per :pincite:`5ITGVDJV`.
|
||||
cms_rule_id="CMS-1848-P",
|
||||
fr_document_number="2026-14327",
|
||||
federal_register_citation="91 FR 43842",
|
||||
published=date(2026, 7, 16),
|
||||
comment_close=date(2026, 9, 14),
|
||||
# Nonqualifying-APM (non-QP) standard CF — RIA detailed
|
||||
# derivation, file line ~39099 (91 FR 44242): "We estimate the
|
||||
# CY 2027 PFS nonqualifying APM CF to be 32.8409".
|
||||
conversion_factor=32.8409,
|
||||
# Qualifying-APM (QP) standard CF — file line ~39095
|
||||
# (91 FR 44242): "We estimate the CY 2027 PFS qualifying APM
|
||||
# CF to be 33.1693".
|
||||
cf_qp=33.1693,
|
||||
# Nonqualifying-APM anesthesia CF — file line ~39104
|
||||
# (91 FR 44242): "the CY 2027 anesthesia nonqualifying APM CF
|
||||
# to be 20.2143".
|
||||
anesthesia_cf=20.2143,
|
||||
# Qualifying-APM anesthesia CF — file line ~39103
|
||||
# (91 FR 44242): "We estimate the CY 2027 anesthesia
|
||||
# qualifying APM CF to be 20.4165".
|
||||
anesthesia_cf_qp=20.4165,
|
||||
# "0.53 percent positive budget neutrality adjustment", stated
|
||||
# identically for both QP and non-QP CFs at file lines
|
||||
# ~39096 and ~39100 (91 FR 44242).
|
||||
budget_neutrality_adjustor=1.0053,
|
||||
# HCPCS Q3014 — file line ~2261 (91 FR 43843): "the proposed
|
||||
# payment amount for HCPCS code Q3014 (Telehealth originating
|
||||
# site facility fee) is $32.65".
|
||||
telehealth_originating_site_fee=32.65,
|
||||
notes=(
|
||||
"Transcribed from data/fr_downloads/2026-14327.txt (NPRM "
|
||||
"full text) per :pincite:`5ITGVDJV`. Standard and "
|
||||
"anesthesia QP/non-QP CFs plus the BN adjustor are from "
|
||||
"the Regulatory Impact Analysis's detailed CF derivation "
|
||||
"(section VII.D, file lines ~39088-39106, 91 FR "
|
||||
"44242) — the earlier plain-language Summary of Costs and "
|
||||
"Benefits (file lines ~359-364, 91 FR 43842) states the "
|
||||
"same $0.40/$0.56 deltas but pairs them with the WRONG "
|
||||
"'current' (CY2026) CF labels/values (mixes CY2026 final "
|
||||
"non-QP $33.4009 with CY2026 *proposed* QP $33.5875, "
|
||||
"rather than CY2026 final QP $33.5675) — an apparent "
|
||||
"drafting error in the published NPRM text itself, not a "
|
||||
"transcription artifact here. Update-factor components "
|
||||
"(also file lines ~39085-39087): QP update +0.75%, "
|
||||
"non-QP update +0.25% (section 1848(d)(20)), not modelled "
|
||||
"as separate fields. Comment-close DATES block at file "
|
||||
"lines 108-109 (91 FR 43842)."
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def proposed_for(year: int) -> ProposedRule | None:
|
||||
"""Return the NPRM parameters for ``year``, wherever they live.
|
||||
|
||||
Looks up ``RULES[year].proposed`` when ``year`` has a Final Rule
|
||||
entry in ``RULES``; otherwise falls back to ``PROPOSED.get(year)``
|
||||
for years whose NPRM has no Final Rule yet (e.g. CY2027 as of this
|
||||
writing — see ``PROPOSED`` above). Returns ``None`` if neither
|
||||
source has proposed parameters for ``year``.
|
||||
|
||||
This indirection is what lets a year migrate from ``PROPOSED`` to
|
||||
``RULES[year].proposed`` once its Final Rule ships without
|
||||
breaking callers: ``proposed_for(year)`` keeps working across that
|
||||
move since it checks ``RULES`` first.
|
||||
"""
|
||||
if year in RULES:
|
||||
return RULES[year].proposed
|
||||
return PROPOSED.get(year)
|
||||
|
||||
|
||||
# ── Convenience aliases ──────────────────────────────────────────
|
||||
|
||||
cy2024 = RULES[2024]
|
||||
|
||||
@@ -39,16 +39,28 @@ the first split-CF year) and ``False`` for earlier performance
|
||||
years. See :class:`pfs.rules.RuleYear` (``cf_qp`` /
|
||||
``conversion_factor`` on ``RULES[2026]``) for the actual dollar CFs.
|
||||
|
||||
APM Incentive Payment sunset
|
||||
------------------------------
|
||||
APM Incentive Payment sunset — and CAA 2026 revival for 2028
|
||||
----------------------------------------------------------------
|
||||
|
||||
The lump-sum APM Incentive Payment (a fixed percentage of a QP's
|
||||
prior-year Part B paid claims) is statutorily phased out: 5% for
|
||||
payment years 2019-2024, 3.5% for payment year 2025 (QP Performance
|
||||
Period 2023), 1.88% for payment year 2026 (QP Performance Period
|
||||
2024) — and payment year 2026 is, under current law, the *last* year
|
||||
these payments are made. ``QppYear.apm_incentive_pct`` is ``None``
|
||||
for QP Performance Periods 2025 and later.
|
||||
prior-year Part B paid claims) is statutorily set at: 5% for payment
|
||||
years 2019-2024, 3.5% for payment year 2025 (QP Performance Period
|
||||
2023), 1.88% for payment year 2026 (QP Performance Period 2024).
|
||||
Payment year 2026 was, under prior law, believed the *last* year
|
||||
these payments would be made — ``QppYear.apm_incentive_pct`` is
|
||||
accordingly ``None`` for payment year 2027 (QP Performance Period
|
||||
2025). The Consolidated Appropriations Act, 2026 (CAA 2026) (Pub. L.
|
||||
119-75, February 3, 2026) revived a **3.1%** APM Incentive Payment,
|
||||
but only for payment year 2028 (QP Performance Period 2026) — 42 CFR
|
||||
414.1450(b)(1)(iv), as amended by CMS-1848-P (91 FR 44286, transcribed
|
||||
``data/fr_downloads/2026-14327.txt:42632``, "(iv) 2028: 3.1
|
||||
percent."); narrative at 91 FR 44218-44219
|
||||
(``2026-14327.txt:37389-37391``, and the proposed-regulatory-text
|
||||
preview at ``:37436``). There is no revival for payment year 2029 (QP
|
||||
Performance Period 2027) — ``QppYear.apm_incentive_pct`` is ``None``
|
||||
there too. So the payment years with a nonzero incentive are 2025,
|
||||
2026, and (revived) 2028 only — not a clean sunset after 2026. See
|
||||
``QPP[2026].apm_incentive_pct`` and task #620.
|
||||
|
||||
CMS-1832-P (CY2026 PFS proposed rule, 90 FR 32352, transcribed from
|
||||
``data/fr_downloads/2025-13271.txt``) proposed several QPP
|
||||
@@ -98,6 +110,143 @@ statement of the Medicare-option figure. Rather than reuse that
|
||||
number under a different provision's field, ``benchmark_nominal_pct``
|
||||
is left ``None`` on every entry, with the gap documented in
|
||||
``RiskStandards.notes``.
|
||||
|
||||
CMS-1848-P (CY2027 PFS proposed rule) — QPP[2027]
|
||||
----------------------------------------------------
|
||||
|
||||
CMS-1848-P (91 FR 43842, published July 16, 2026, transcribed from
|
||||
``data/fr_downloads/2026-14327.txt``) is captured on ``QPP[2027]``
|
||||
(QP Performance Period 2027, payment year 2029). Ingested into Zotero
|
||||
as :pincite:`5ITGVDJV`. No Final Rule (CMS-1848-F) had been published
|
||||
as of this transcription, so ``QPP[2027].proposed.changes`` states
|
||||
proposals only, with no finalized-disposition information available.
|
||||
|
||||
QPP[2027] current-law thresholds are transcribed from two sources that
|
||||
agree: the RIA recital "The thresholds to achieve QP status in the
|
||||
2027 QP Performance Period (2029 payment year) are set to 75 percent
|
||||
for the payment amount, and 50 percent for the patient count." (91 FR
|
||||
44260, line 40311-40313), and the amendatory regulatory text at 42 CFR
|
||||
414.1430(a)(1)(v)/(a)(3)(v) "2029 and thereafter: 75 percent" /
|
||||
"50 percent" (91 FR 44285-44286, line 42551/42569) — the same
|
||||
amendatory text at (a)(2)(v)/(a)(4)(v) gives the Partial QP thresholds
|
||||
"2029 and thereafter: 50 percent" / "35 percent" (91 FR 44286, line
|
||||
42559/42576). No amendment to 42 CFR 414.1415 (nominal amount
|
||||
standard / CEHRT) or 414.1420 (Other Payer Advanced APM standard) is
|
||||
proposed in this NPRM — the amendatory-text item list runs "44. Sec.
|
||||
414.1400 ... 45. Sec. 414.1425 ..." (line 42368, 42504), skipping both
|
||||
sections — so ``risk_standards`` and ``cehrt_required`` are carried
|
||||
forward unchanged, corroborated indirectly by this NPRM's own
|
||||
statement that the Shared Savings Program ENHANCED track Level E loss
|
||||
recoupment limit — independently stated as "shall not exceed 8
|
||||
percent of total Medicare Parts A and B FFS revenue" (91 FR 44069,
|
||||
line 23890-23891) — equals "the revenue-based nominal amount standard
|
||||
under the Quality Payment Program (QPP) (42 CFR 414.1415(c)(3)(i)(A))"
|
||||
(line 23894-23896), consistent with the existing 8% figure.
|
||||
``qp_cf_applies`` is True: "eligible clinicians who are QPs for the
|
||||
2024 performance year/2026 payment year and beyond will receive an
|
||||
increased physician fee schedule update of 0.75 percent qualifying
|
||||
APM conversion factor" (91 FR 44217, line 30603-30606) confirms the
|
||||
split-CF policy is not time-limited and persists through payment year
|
||||
2029. ``apm_incentive_pct`` is None: the amendatory text at 42 CFR
|
||||
414.1450(b)(1) (91 FR 44286, line 42629-42632) lists applicable
|
||||
percentages only for payment years 2019-2024, 2025, 2026, and 2028 —
|
||||
no entry for payment year 2027 or 2029 — so under current law as
|
||||
restated by this NPRM, no lump-sum APM Incentive Payment is payable
|
||||
for payment year 2029 (the sunset already captured on QPP[2025]/
|
||||
QPP[2026] has a gap, not a clean cutoff — see next section).
|
||||
|
||||
CMS-1848-P's substantive Advanced-APM-relevant proposals (see
|
||||
``QPP[2027].proposed.changes`` for full sourcing) are: (1) applying QP
|
||||
(and, by conforming amendment, Partial QP) status strictly at the
|
||||
TIN/NPI level rather than to the clinician across all TIN
|
||||
reassignments, beginning with the 2027 QP Performance Period (91 FR
|
||||
44215-44217, 44285); (2) a clerical fix to 42 CFR 414.1425(c)(5)(ii)
|
||||
removing an erroneously-included clause; (3) a clarification for APMs
|
||||
without a Participation List; (4) a restructuring/condensing of 42
|
||||
CFR 414.1430(a)/(b) to conform the regulation text to already-enacted
|
||||
statutory threshold levels (not a new CMS-set threshold change); and
|
||||
(5) codifying the CAA 2026's 3.1% APM Incentive Payment extension for
|
||||
payment year 2028 and restructuring 414.1450(b)(1) into a per-year
|
||||
list.
|
||||
|
||||
**Payment-year keying correction — QPP[2023], QPP[2024] (task #620,
|
||||
refs #613):** An earlier transcription (P36) misread 42 CFR
|
||||
414.1430(a)'s payment-year-keyed threshold schedule as keyed to *QP
|
||||
Performance Period*, carrying the 75%/50% QP (50%/35% Partial QP)
|
||||
step-up back two years too early onto ``QPP[2023]``/``QPP[2024]``.
|
||||
The statute (section 1833(z)(2) of the Act) and its implementing
|
||||
regulation key strictly to **payment year**:
|
||||
|
||||
- 42 CFR 414.1430(a)(1)(ii)/(a)(3)(ii): "2021 through 2026: 50
|
||||
percent" / "35 percent" (QP payment-amount / patient-count) —
|
||||
CMS-1848-P amendatory text, 91 FR 44285
|
||||
(``data/fr_downloads/2026-14327.txt:42548,42566``).
|
||||
- 42 CFR 414.1430(a)(2)(ii)/(a)(4)(ii): "2021 through 2026: 40
|
||||
percent" / "25 percent" (Partial QP) — 91 FR 44285-44286
|
||||
(``2026-14327.txt:42556,42573``).
|
||||
|
||||
Original source: 42 CFR 414.1430(a), 89 FR 98564 (CY2024 PFS Final
|
||||
Rule); restated in CMS-1848-P's amendatory text at 91 FR 44285-44286;
|
||||
the CAA 2024 Sec. 304 narrative extending payment-year-2025
|
||||
thresholds through payment year 2026 is at 91 FR 44219
|
||||
(``2026-14327.txt:37498-37509``: "section 304(a)(2) of the CAA, 2024
|
||||
... extend[ed] for payment years 2025 and 2026 (performance periods
|
||||
2023 and 2024) the applicable payment amount and patient count
|
||||
thresholds"). Accordingly, ``QPP[2023]`` (payment year 2025) and
|
||||
``QPP[2024]`` (payment year 2026) are corrected from 75%/50% QP,
|
||||
50%/35% Partial QP to **50%/35% QP, 40%/25% Partial QP**.
|
||||
``QPP[2025]`` (payment year 2027) and ``QPP[2027]`` (payment year
|
||||
2029) were already correct at 75%/50% QP, 50%/35% Partial QP and are
|
||||
**unchanged** by this correction.
|
||||
|
||||
**CAA 2026 supersession — QPP[2026] (task #620):** ``QPP[2026]``
|
||||
(payment year 2028) was previously coded 75%/50% QP, 50%/35% Partial
|
||||
QP, ``apm_incentive_pct=None`` — accurate when transcribed from the
|
||||
CY2026 PFS Final Rule narrative ("The thresholds to achieve QP status
|
||||
in the 2026 QP Performance Period (2028 payment year) are set to 75
|
||||
percent for the payment amount, and 50 percent for the patient
|
||||
count.", 90 FR 49980), which was itself correct *at the time of that
|
||||
rule's publication*. The Consolidated Appropriations Act, 2026 (CAA
|
||||
2026) (Pub. L. 119-75, February 3, 2026) subsequently, legislatively
|
||||
restored payment year 2028 to the lower 50%/35% QP, 40%/25% Partial QP
|
||||
levels and revived a 3.1% APM Incentive Payment for that year — see
|
||||
CMS-1848-P's own narrative, 91 FR 44219-44220
|
||||
(``2026-14327.txt:37536-37541``: "these thresholds all increased for
|
||||
payment year 2027 ... and were legislatively restored to lower levels
|
||||
for payment year 2028") and the amendatory text at 91 FR 44286 (QP/
|
||||
Partial QP thresholds: ``2026-14327.txt:42550,42558,42568,42575``;
|
||||
APM incentive: ``:42632``, "(iv) 2028: 3.1 percent."). **This
|
||||
statutory change is self-executing and controls regardless of
|
||||
regulatory-text conformance status** — CMS-1848-P's own amendment of
|
||||
42 CFR 414.1430/414.1450 to match is only *proposed* as of this
|
||||
transcription (not yet a Final Rule), and eCFR's currently-displayed
|
||||
text for these sections does not yet reflect CAA 2026 and does **not**
|
||||
control. ``QPP[2026]`` is therefore corrected to ``qp_thresholds``
|
||||
50%/35%, ``partial_qp_thresholds`` 40%/25%, and
|
||||
``apm_incentive_pct=3.1``, superseding the CY2026 Final Rule narrative
|
||||
(90 FR 49980) this module originally transcribed. See
|
||||
``QPP[2026].proposed.changes`` for how this interacts with CMS-1832-P's
|
||||
(correct, at the time) "no numeric change" disposition, and
|
||||
task-620-report.md for the full correction table.
|
||||
|
||||
**CMS drafting-error note (not a data error in this module):**
|
||||
CMS-1848-P's own amendatory text contains an internal inconsistency at
|
||||
42 CFR 414.1430(b)(2)(i)(D) — the All-Payer Combination Option Partial
|
||||
QP payment-amount threshold for "2029 and thereafter" is printed as
|
||||
"75 percent" (91 FR 44286, ``2026-14327.txt:42589``), matching the
|
||||
*QP* (non-Partial) threshold at the parallel (b)(1)(i)(D)
|
||||
(``:42583``) rather than being lower than it, and conflicting with the
|
||||
Medicare Option Partial QP payment-amount threshold for the same
|
||||
period at 414.1430(a)(2)(v), correctly printed as "50 percent"
|
||||
(``:42559``). Partial QP is by design the *lower* bar, so a Partial QP
|
||||
figure equal to the QP figure is incoherent; this reads as a
|
||||
copy-paste error in the NPRM text, not a substantive Medicare-Option-
|
||||
vs-All-Payer-Option policy difference. This module does not encode the
|
||||
All-Payer Combination Option thresholds (only the generally-applicable
|
||||
Medicare Option ones), so this drafting error does not propagate into
|
||||
any field here — noted so a future grepper who spots "75 percent" at
|
||||
(b)(2)(i)(D) does not mistake it for evidence that this module's 2029
|
||||
Partial QP figure (50%, sourced from (a)(2)(v)) is wrong.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -212,9 +361,14 @@ class QppYear(BaseModel):
|
||||
|
||||
apm_incentive_pct: float | None
|
||||
"""Lump-sum APM Incentive Payment, as a % of the QP's base-year
|
||||
(payment_year - 1) paid claims for covered professional services —
|
||||
``None`` once the statutory sunset takes effect (payment years
|
||||
2027+, i.e. performance years 2025+)."""
|
||||
(payment_year - 1) paid claims for covered professional services.
|
||||
``None`` for payment years without a payment under current law:
|
||||
originally believed to sunset after payment year 2026 (performance
|
||||
year 2024), but the Consolidated Appropriations Act, 2026 (CAA
|
||||
2026) revived a 3.1% payment for payment year 2028 (performance
|
||||
year 2026) only — payment years 2027 and 2029 remain ``None``. See
|
||||
module docstring "APM Incentive Payment sunset — and CAA 2026
|
||||
revival for 2028"."""
|
||||
|
||||
qp_cf_applies: bool
|
||||
"""``True`` once the payment year for this performance period uses
|
||||
@@ -237,60 +391,62 @@ class QppYear(BaseModel):
|
||||
|
||||
# ── Annual QPP registry ──────────────────────────────────────────
|
||||
#
|
||||
# QP / Partial QP thresholds: the statutory schedule (section
|
||||
# 1833(z)(2) of the Act) raised the Medicare-option thresholds to 75%
|
||||
# (payment amount) / 50% (patient count) effective QP Performance
|
||||
# Period 2023, with Partial QP thresholds set at the prior period's
|
||||
# QP levels — 50% / 35%. Transcribed from data/fr_downloads/
|
||||
# 2025-13271.txt (NPRM) and cross-checked against data/fr_downloads/
|
||||
# 2025-19787.txt (Final Rule):
|
||||
# QP / Partial QP thresholds are keyed to PAYMENT YEAR by both the
|
||||
# statute (section 1833(z)(2) of the Act) and 42 CFR 414.1430(a) — NOT
|
||||
# to QP Performance Period. (An earlier version of this module read
|
||||
# the statutory 75%/50% step-up as keyed to performance period,
|
||||
# shifting it two years too early onto QPP[2023]/QPP[2024]; corrected
|
||||
# under task #620 / issue #613 — see module docstring "Payment-year
|
||||
# keying correction".) The current-law payment-year schedule per 42
|
||||
# CFR 414.1430(a), as amended through CMS-1848-P's amendatory text (91
|
||||
# FR 44285-44286, data/fr_downloads/2026-14327.txt:42546-42576) and
|
||||
# the CAA 2026 supersession of payment year 2028 (module docstring
|
||||
# "CAA 2026 supersession"), is:
|
||||
#
|
||||
# - NPRM 90 FR 32772 (line 36531-36537): "...the QP thresholds have
|
||||
# increased relative to previous years and, effective with the
|
||||
# 2025 QP Performance Period are now at 75 percent for the
|
||||
# payment amount and 50 percent for the patient count."
|
||||
# - NPRM 90 FR 32819 (line 40647-40656, Regulatory Impact Analysis):
|
||||
# "the thresholds to achieve QP status beginning in the 2026 QP
|
||||
# Performance Period ... will increase from 50 percent to 75
|
||||
# percent for the payment amount, and from 35 percent to 50
|
||||
# percent for the patient count." This RIA sentence is internally
|
||||
# inconsistent with the 90 FR 32772 recital above (both appear in
|
||||
# the same NPRM) — it reads as stale boilerplate describing the
|
||||
# pre-2023 -> 2023 statutory step-up, not a change CMS-1832-P
|
||||
# itself proposes.
|
||||
# - Final Rule 90 FR 49980 (line 69796-69798; corrected from a
|
||||
# mistranscribed "90 FR 49928" — that page marker is at line 64990,
|
||||
# these lines fall between the [[Page 49980]] marker at line 69792
|
||||
# and the [[Page 49981]] marker at line 69913) drops the "increase
|
||||
# from" framing entirely: "The thresholds to achieve QP status in
|
||||
# the 2026 QP Performance Period (2028 payment year) are set to
|
||||
# 75 percent for the payment amount, and 50 percent for the
|
||||
# patient count" — consistent with 75/50 being the unchanged,
|
||||
# already-current value, not a new increase.
|
||||
# - Final Rule 90 FR 49928 (line 65021-65026): "...if the proposed
|
||||
# policy would result in an ACO's payment amount score increasing
|
||||
# from 48 percent to 51 percent, that would cross the 50 percent
|
||||
# Partial QP threshold" — confirms Partial QP payment-amount
|
||||
# threshold = 50%. The Partial QP patient-count threshold (35%) is
|
||||
# not independently restated as a bare number in either document;
|
||||
# it is inferred from the same NPRM RIA sentence's "35 percent"
|
||||
# figure (the pre-2023 QP patient-count digit, which by the
|
||||
# statute's step-down design becomes the post-2023 Partial QP
|
||||
# digit) — see module docstring caveat.
|
||||
# payment year QP (amt / cnt) Partial QP (amt / cnt)
|
||||
# 2021-2026 50% / 35% 40% / 25%
|
||||
# 2027 75% / 50% 50% / 35%
|
||||
# 2028 50% / 35% 40% / 25% (CAA 2026 revival)
|
||||
# 2029+ 75% / 50% 50% / 35%
|
||||
#
|
||||
# Mapped onto this registry's performance-year keys (payment_year =
|
||||
# performance_year + 2):
|
||||
#
|
||||
# - QPP[2023] (pay 2025), QPP[2024] (pay 2026): 50%/35% QP, 40%/25%
|
||||
# Partial QP — 42 CFR 414.1430(a)(1)(ii)/(a)(2)(ii)/(a)(3)(ii)/
|
||||
# (a)(4)(ii), 91 FR 44285-44286; original source 89 FR 98564
|
||||
# (CY2024 PFS Final Rule); CAA 2024 Sec. 304 narrative, 91 FR
|
||||
# 44219.
|
||||
# - QPP[2025] (pay 2027), QPP[2027] (pay 2029): 75%/50% QP, 50%/35%
|
||||
# Partial QP — unchanged; see each entry's own citation below.
|
||||
# - QPP[2026] (pay 2028): 50%/35% QP, 40%/25% Partial QP, plus a
|
||||
# revived 3.1% APM Incentive Payment — CAA 2026, superseding the
|
||||
# CY2026 Final Rule narrative (90 FR 49980) this module originally
|
||||
# transcribed; see module docstring "CAA 2026 supersession".
|
||||
#
|
||||
# ``_qp_thresholds_75_50``/``_qp_thresholds_50_35`` and
|
||||
# ``_partial_qp_thresholds_50_35``/``_partial_qp_thresholds_40_25``
|
||||
# below supply the two distinct threshold-sets in play across
|
||||
# 2023-2027; they are no longer a single constant-returning helper
|
||||
# because the values now differ by payment year.
|
||||
#
|
||||
# Risk standards: see module docstring "Nominal amount standard
|
||||
# sourcing caveat" — 8% revenue-based (42 CFR 414.1415(c)(3)(i)(A)),
|
||||
# unchanged across 2023-2026 (no proposed or finalized change found in
|
||||
# either CY2026 document). Benchmark-based standard (42 CFR
|
||||
# unchanged across 2023-2027 (no proposed or finalized change found in
|
||||
# any FR document reviewed). Benchmark-based standard (42 CFR
|
||||
# 414.1415(c)(3)(i)(B)) left None — not stated as a bare number
|
||||
# anywhere in this repository's FR corpus.
|
||||
#
|
||||
# APM incentive %% and qp_cf_applies: transcribed from NPRM 90 FR
|
||||
# APM incentive % and qp_cf_applies: transcribed from NPRM 90 FR
|
||||
# 32818 / Final Rule 90 FR 49979-49980 ("b. APM Incentive Payments to
|
||||
# QPs in Advanced APMs and Other Payer Advanced APMs"), and the
|
||||
# sunset statement at NPRM 90 FR 32696 / Final Rule 90 FR 49838
|
||||
# ("Under current law, payment year 2026 is the last year for these
|
||||
# payments.").
|
||||
# payments.") — since revised for payment year 2028 by CAA 2026, per
|
||||
# CMS-1848-P, 91 FR 44218-44219, 44286
|
||||
# (data/fr_downloads/2026-14327.txt:37389-37391, 37436, 42632); see
|
||||
# module docstring "APM Incentive Payment sunset — and CAA 2026
|
||||
# revival for 2028".
|
||||
|
||||
_RISK_NOTES = (
|
||||
"Revenue-based 8% per CY2019 PFS Final Rule (83 FR 59923, "
|
||||
@@ -321,67 +477,129 @@ def _risk_standards() -> RiskStandards:
|
||||
)
|
||||
|
||||
|
||||
def _qp_thresholds() -> QpThresholds:
|
||||
def _qp_thresholds_75_50() -> QpThresholds:
|
||||
"""QP thresholds for payment years 2027 and 2029+ (42 CFR
|
||||
414.1430(a)(1)(iii)/(v), (a)(3)(iii)/(v))."""
|
||||
return QpThresholds(payment_amount_pct=75.0, patient_count_pct=50.0)
|
||||
|
||||
|
||||
def _partial_qp_thresholds() -> QpThresholds:
|
||||
def _qp_thresholds_50_35() -> QpThresholds:
|
||||
"""QP thresholds for payment years 2021-2026 and 2028 (CAA 2026
|
||||
revival) — 42 CFR 414.1430(a)(1)(ii)/(iv), (a)(3)(ii)/(iv), 91 FR
|
||||
44285-44286."""
|
||||
return QpThresholds(payment_amount_pct=50.0, patient_count_pct=35.0)
|
||||
|
||||
|
||||
def _partial_qp_thresholds_50_35() -> QpThresholds:
|
||||
"""Partial QP thresholds for payment years 2027 and 2029+ (42 CFR
|
||||
414.1430(a)(2)(iii)/(v), (a)(4)(iii)/(v))."""
|
||||
return QpThresholds(payment_amount_pct=50.0, patient_count_pct=35.0)
|
||||
|
||||
|
||||
def _partial_qp_thresholds_40_25() -> QpThresholds:
|
||||
"""Partial QP thresholds for payment years 2021-2026 and 2028 (CAA
|
||||
2026 revival) — 42 CFR 414.1430(a)(2)(ii)/(iv), (a)(4)(ii)/(iv), 91
|
||||
FR 44285-44286."""
|
||||
return QpThresholds(payment_amount_pct=40.0, patient_count_pct=25.0)
|
||||
|
||||
|
||||
QPP: dict[int, QppYear] = {
|
||||
2023: QppYear(
|
||||
# Payment year 2025: 42 CFR 414.1430(a)(1)(ii)/(a)(3)(ii)
|
||||
# "2021 through 2026: 50 percent" / "35 percent" (QP), 91 FR
|
||||
# 44285; (a)(2)(ii)/(a)(4)(ii) "40 percent" / "25 percent"
|
||||
# (Partial QP), 91 FR 44285-44286 — CMS-1848-P amendatory text
|
||||
# (data/fr_downloads/2026-14327.txt:42548,42556,42566,42573),
|
||||
# restating 89 FR 98564 (CY2024 PFS Final Rule) as extended by
|
||||
# CAA 2024 Sec. 304 (91 FR 44219). Corrected from an earlier
|
||||
# payment-year-keying error (task #620) — see module docstring
|
||||
# "Payment-year keying correction".
|
||||
performance_year=2023,
|
||||
payment_year=2025,
|
||||
qp_thresholds=_qp_thresholds(),
|
||||
partial_qp_thresholds=_partial_qp_thresholds(),
|
||||
qp_thresholds=_qp_thresholds_50_35(),
|
||||
partial_qp_thresholds=_partial_qp_thresholds_40_25(),
|
||||
risk_standards=_risk_standards(),
|
||||
apm_incentive_pct=3.5,
|
||||
qp_cf_applies=False,
|
||||
citation="90 FR 49979-49980",
|
||||
citation=(
|
||||
"90 FR 49979-49980 (APM incentive); thresholds: 89 FR 98564 "
|
||||
"(CY2024 PFS Final Rule), restated 91 FR 44285-44286 "
|
||||
"(CMS-1848-P); CAA 2024 Sec. 304, 91 FR 44219 (task #620)"
|
||||
),
|
||||
),
|
||||
2024: QppYear(
|
||||
# Payment year 2026 is the first year with a differential
|
||||
# QP/non-QP conversion factor (section 1848(d)(20) of the
|
||||
# Act) AND still receives the (final year of) lump-sum APM
|
||||
# Incentive Payment — see NPRM 90 FR 32818, Final Rule 90 FR
|
||||
# Act) AND is the last year of the original uninterrupted
|
||||
# lump-sum APM Incentive Payment run (2019-2026) before the
|
||||
# 2027 gap and the CAA-2026-only revival for 2028 (see
|
||||
# QPP[2026]) — see NPRM 90 FR 32818, Final Rule 90 FR
|
||||
# 49979-49980.
|
||||
# Thresholds: 42 CFR 414.1430(a)(1)(ii)/(a)(3)(ii) "2021
|
||||
# through 2026: 50 percent" / "35 percent" (QP), 91 FR 44285;
|
||||
# (a)(2)(ii)/(a)(4)(ii) "40 percent" / "25 percent" (Partial
|
||||
# QP), 91 FR 44285-44286 — same citations as QPP[2023].
|
||||
# Corrected from an earlier payment-year-keying error (task
|
||||
# #620) — see module docstring "Payment-year keying
|
||||
# correction".
|
||||
performance_year=2024,
|
||||
payment_year=2026,
|
||||
qp_thresholds=_qp_thresholds(),
|
||||
partial_qp_thresholds=_partial_qp_thresholds(),
|
||||
qp_thresholds=_qp_thresholds_50_35(),
|
||||
partial_qp_thresholds=_partial_qp_thresholds_40_25(),
|
||||
risk_standards=_risk_standards(),
|
||||
apm_incentive_pct=1.88,
|
||||
qp_cf_applies=True,
|
||||
citation="90 FR 49979-49980",
|
||||
citation=(
|
||||
"90 FR 49979-49980 (APM incentive, qp_cf_applies); "
|
||||
"thresholds: 89 FR 98564 (CY2024 PFS Final Rule), restated "
|
||||
"91 FR 44285-44286 (CMS-1848-P); CAA 2024 Sec. 304, 91 FR "
|
||||
"44219 (task #620)"
|
||||
),
|
||||
),
|
||||
2025: QppYear(
|
||||
# Under current law, payment year 2026 (performance year
|
||||
# 2024) was the last year with a lump-sum APM Incentive
|
||||
# Payment — NPRM 90 FR 32696, Final Rule 90 FR 49838.
|
||||
# Under prior law, payment year 2026 (performance year 2024)
|
||||
# was believed the last year with a lump-sum APM Incentive
|
||||
# Payment — NPRM 90 FR 32696, Final Rule 90 FR 49838. CAA 2026
|
||||
# subsequently revived a 3.1% incentive for payment year 2028
|
||||
# only (see QPP[2026]); payment year 2027 (this entry) still
|
||||
# has no incentive under any law reviewed.
|
||||
performance_year=2025,
|
||||
payment_year=2027,
|
||||
qp_thresholds=_qp_thresholds(),
|
||||
partial_qp_thresholds=_partial_qp_thresholds(),
|
||||
qp_thresholds=_qp_thresholds_75_50(),
|
||||
partial_qp_thresholds=_partial_qp_thresholds_50_35(),
|
||||
risk_standards=_risk_standards(),
|
||||
apm_incentive_pct=None,
|
||||
qp_cf_applies=True,
|
||||
citation="90 FR 49838",
|
||||
),
|
||||
2026: QppYear(
|
||||
# Payment year 2028: thresholds and apm_incentive_pct are
|
||||
# corrected (task #620) to reflect the Consolidated
|
||||
# Appropriations Act, 2026 (CAA 2026), which superseded the
|
||||
# CY2026 Final Rule's (accurate-at-the-time) 75%/50% QP,
|
||||
# 50%/35% Partial QP, no-incentive narrative. See module
|
||||
# docstring "CAA 2026 supersession" for the full citation
|
||||
# chain: 42 CFR 414.1430(a)(1)(iv)/(a)(3)(iv) "2028: 50
|
||||
# percent" / "35 percent" (QP), (a)(2)(iv)/(a)(4)(iv) "40
|
||||
# percent" / "25 percent" (Partial QP), and 414.1450(b)(1)(iv)
|
||||
# "2028: 3.1 percent" (APM incentive) — all 91 FR 44286,
|
||||
# CMS-1848-P amendatory text
|
||||
# (data/fr_downloads/2026-14327.txt:42550,42558,42568,42575,
|
||||
# 42632). This is a statutory, self-executing supersession;
|
||||
# eCFR's unconformed text does not control.
|
||||
performance_year=2026,
|
||||
payment_year=2028,
|
||||
qp_thresholds=_qp_thresholds(),
|
||||
partial_qp_thresholds=_partial_qp_thresholds(),
|
||||
qp_thresholds=_qp_thresholds_50_35(),
|
||||
partial_qp_thresholds=_partial_qp_thresholds_40_25(),
|
||||
risk_standards=_risk_standards(),
|
||||
apm_incentive_pct=None,
|
||||
apm_incentive_pct=3.1,
|
||||
qp_cf_applies=True,
|
||||
# Corrected from "90 FR 49928" alone: the QP threshold statement
|
||||
# itself is at 90 FR 49980 (see docstring pincite above); 49928
|
||||
# is retained alongside it since it's independently the source
|
||||
# for the Partial QP payment-amount confirmation (docstring
|
||||
# pincite, line 65021-65026).
|
||||
citation="90 FR 49928, 49980",
|
||||
citation=(
|
||||
"90 FR 49928, 49980 (CY2026 Final Rule narrative — "
|
||||
"accurate when published, since SUPERSEDED); corrected "
|
||||
"per CAA 2026 (Pub. L. 119-75), restated 91 FR 44218-44220, "
|
||||
"44285-44286 (CMS-1848-P) (task #620)"
|
||||
),
|
||||
# NPRM proposals per :pincite:`2KVJ2HKX` — CMS-1832-P, 90 FR
|
||||
# 32352, transcribed from data/fr_downloads/2025-13271.txt;
|
||||
# disposition from data/fr_downloads/2025-19787.txt.
|
||||
@@ -416,13 +634,24 @@ QPP: dict[int, QppYear] = {
|
||||
"beginning with the 2026 QP Performance Period (NPRM "
|
||||
"90 FR 32773). Finalized as proposed (Final Rule 90 FR "
|
||||
"49929: 'finalizing this policy as proposed'). "
|
||||
"QP/Partial QP thresholds: proposed no numeric change "
|
||||
"— thresholds remain 75%/50% (QP) and 50%/35% "
|
||||
"(Partial QP); see QPP[2026].qp_thresholds and the "
|
||||
"module docstring re: the NPRM's internally "
|
||||
"inconsistent RIA framing, corrected in the Final "
|
||||
"Rule (NPRM 90 FR 32772 vs 90 FR 32819; Final Rule 90 "
|
||||
"FR 49928). "
|
||||
"QP/Partial QP thresholds: CMS-1832-P proposed, and "
|
||||
"the Final Rule finalized, no numeric change from the "
|
||||
"then-current-law payment-year-2028 thresholds "
|
||||
"(75%/50% QP, 50%/35% Partial QP; see module "
|
||||
"docstring re: the NPRM's internally inconsistent RIA "
|
||||
"framing, corrected in the Final Rule — NPRM 90 FR "
|
||||
"32772 vs 90 FR 32819; Final Rule 90 FR 49928). That "
|
||||
"disposition was correct as of this rule's "
|
||||
"publication. It was SUBSEQUENTLY SUPERSEDED for "
|
||||
"payment year 2028 by the Consolidated Appropriations "
|
||||
"Act, 2026 (CAA 2026), which legislatively restored "
|
||||
"50%/35% QP, 40%/25% Partial QP and revived a 3.1% "
|
||||
"APM Incentive Payment for that year — see "
|
||||
"QPP[2026].qp_thresholds / partial_qp_thresholds / "
|
||||
"apm_incentive_pct (current law, task #620) and the "
|
||||
'module docstring "CAA 2026 supersession" section; '
|
||||
"do not read this CMS-1832-P disposition as a "
|
||||
"statement of current law. "
|
||||
"Nominal amount standards (42 CFR "
|
||||
"414.1415(c)(3)(i)(A)/(B)) and the CEHRT requirement "
|
||||
"(Sec. 414.1415(a)(i)): proposed no change — neither "
|
||||
@@ -432,6 +661,106 @@ QPP: dict[int, QppYear] = {
|
||||
),
|
||||
),
|
||||
),
|
||||
2027: QppYear(
|
||||
# QP Performance Period 2027 / payment year 2029. Current-law
|
||||
# thresholds transcribed from data/fr_downloads/2026-14327.txt
|
||||
# (91 FR 43842, CMS-1848-P) -- see module docstring "CMS-1848-P
|
||||
# (CY2027 PFS proposed rule) - QPP[2027]" for full sourcing:
|
||||
# - RIA recital, 91 FR 44260 (line 40311-40313): "The
|
||||
# thresholds to achieve QP status in the 2027 QP
|
||||
# Performance Period (2029 payment year) are set to 75
|
||||
# percent for the payment amount, and 50 percent for the
|
||||
# patient count."
|
||||
# - Amendatory text, 42 CFR 414.1430(a)(1)(v)/(a)(3)(v), 91 FR
|
||||
# 44285-44286 (line 42551, 42569): "2029 and thereafter: 75
|
||||
# percent" / "50 percent" (QP); (a)(2)(v)/(a)(4)(v), 91 FR
|
||||
# 44286 (line 42559, 42576): "2029 and thereafter: 50
|
||||
# percent" / "35 percent" (Partial QP).
|
||||
# Risk standards / CEHRT: no amendment to 42 CFR 414.1415 or
|
||||
# 414.1420 in this NPRM (amendatory-text item list skips both,
|
||||
# 91 FR 44284-44285) -- carried forward unchanged; see
|
||||
# module docstring for the indirect Shared Savings Program
|
||||
# Level E cross-check (91 FR 44069).
|
||||
# apm_incentive_pct: no payment-year-2029 entry in the
|
||||
# amendatory text at 42 CFR 414.1450(b)(1), 91 FR 44286 (line
|
||||
# 42629-42632) -- no APM Incentive Payment for this payment
|
||||
# year under current law as restated by this NPRM.
|
||||
# qp_cf_applies: "eligible clinicians who are QPs for the 2024
|
||||
# performance year/2026 payment year and beyond will receive an
|
||||
# increased physician fee schedule update of 0.75 percent
|
||||
# qualifying APM conversion factor" (91 FR 44217, line
|
||||
# 30603-30606) -- split CF persists through payment year 2029.
|
||||
performance_year=2027,
|
||||
payment_year=2029,
|
||||
qp_thresholds=_qp_thresholds_75_50(),
|
||||
partial_qp_thresholds=_partial_qp_thresholds_50_35(),
|
||||
risk_standards=_risk_standards(),
|
||||
apm_incentive_pct=None,
|
||||
qp_cf_applies=True,
|
||||
citation="91 FR 44260, 44285-44286",
|
||||
# NPRM proposals per :pincite:`5ITGVDJV` -- CMS-1848-P, 91 FR
|
||||
# 43842, transcribed from data/fr_downloads/2026-14327.txt. No
|
||||
# Final Rule (CMS-1848-F) published as of this transcription --
|
||||
# proposals only, no finalized disposition available.
|
||||
proposed=QppProposed(
|
||||
cms_rule_id="CMS-1848-P",
|
||||
federal_register_citation="91 FR 43842",
|
||||
changes=(
|
||||
"TIN/NPI-level QP and Partial QP status determination: "
|
||||
"proposed to apply QP status (and, via a conforming "
|
||||
"amendment, Partial QP status) strictly to the "
|
||||
"eligible clinician as a TIN/NPI combination, rather "
|
||||
"than to the clinician (NPI) across all of their TIN "
|
||||
"reassignments as under current policy, beginning with "
|
||||
"the 2027 QP Performance Period (NPRM 91 FR "
|
||||
"44215-44217, section 'Advanced APMs' > 'QP "
|
||||
"Determination' > '(2) Applying QP Determinations at "
|
||||
"the TIN/NPI Level'; amendatory text adding Sec. "
|
||||
"414.1425(c)(8) and (d)(5), 91 FR 44285). "
|
||||
"APM Entity Termination clerical fix: proposed to "
|
||||
"remove an erroneously-included clause ('even if such "
|
||||
"termination date occurs within such QP Performance "
|
||||
"Period') from 42 CFR 414.1425(c)(5)(ii), correcting a "
|
||||
"CY2020-PFS-final-rule clerical error that misaligned "
|
||||
"the QP-status termination text from its Partial-QP "
|
||||
"counterpart at 414.1425(d)(3)(ii) (NPRM 91 FR "
|
||||
"44217-44218). "
|
||||
"Participation List clarification: proposed to clarify "
|
||||
"that APMs without a Participation List are not "
|
||||
"thereby excluded from QP determinations or MIPS APM "
|
||||
"scoring, addressing an interpretive gap for future "
|
||||
"episode-based/mandatory APM designs (NPRM 91 FR "
|
||||
"44218). "
|
||||
"QP/Partial QP threshold regulatory-text conformance: "
|
||||
"proposed to restructure and condense 42 CFR "
|
||||
"414.1430(a)/(b) into a consolidated payment-year "
|
||||
"schedule, conforming the regulation text to "
|
||||
"already-enacted statutory changes (CAA 2024's "
|
||||
"extension of payment-year-2025 thresholds through "
|
||||
"payment year 2026, and subsequently legislated "
|
||||
"payment-year 2027/2028/2029+ levels) rather than "
|
||||
"proposing new CMS-set thresholds (NPRM 91 FR "
|
||||
"44219-44220; amendatory text 91 FR 44285-44286). See "
|
||||
"QPP[2027].qp_thresholds/partial_qp_thresholds for the "
|
||||
"values as restated for payment year 2029. "
|
||||
"APM Incentive Payment: proposed to codify the "
|
||||
"Consolidated Appropriations Act, 2026's extension of "
|
||||
"a 3.1 percent lump-sum APM Incentive Payment to "
|
||||
"payment year 2028 (2026 QP Performance Period), and "
|
||||
"to restructure 42 CFR 414.1450(b)(1) into a per-year "
|
||||
"subparagraph list for readability, with no "
|
||||
"substantive change to any other year's percentage "
|
||||
"(NPRM 91 FR 44218-44219; RIA 91 FR 44260; amendatory "
|
||||
"text 91 FR 44286). This amendatory text lists no "
|
||||
"applicable percentage for payment years 2027 or 2029 "
|
||||
"(i.e., no incentive payment in those years) and 3.1 "
|
||||
"percent for payment year 2028 -- the latter directly "
|
||||
"bears on QPP[2026].apm_incentive_pct (payment year "
|
||||
"2028), which was corrected under task #620; see "
|
||||
"module docstring 'CAA 2026 supersession'."
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -145,8 +145,17 @@ CLASSIFY_TOOL = LLMTool(
|
||||
# Verified against the regulations.gov docket abstract (2026-08-12):
|
||||
# CMS-2025-0304 is the CY2026 *PFS* NPRM, not the OPPS rule — skin-substitute
|
||||
# payment moved into the PFS rule in CY2026.
|
||||
DOCKET_RULES: dict[str, str] = {"CMS-2025-0304": "CMS-1832-P"}
|
||||
# CMS-2026-2377 is the CY2027 PFS NPRM (verified against the FR API 2026-08-13 —
|
||||
# FR doc 2026-14327, 91 FR 43842).
|
||||
DOCKET_RULES: dict[str, str] = {
|
||||
"CMS-2025-0304": "CMS-1832-P",
|
||||
"CMS-2026-2377": "CMS-1848-P",
|
||||
}
|
||||
|
||||
# This prompt is scoped to docket CMS-2025-0304 only (skin-substitute
|
||||
# reclassification). DOCKET_RULES above maps more dockets (e.g.
|
||||
# CMS-2026-2377) to their CMS rule IDs, but do not reuse this prompt
|
||||
# for CMS-2026-2377 or any other docket without rewriting it.
|
||||
_SYSTEM = """\
|
||||
You classify public comments submitted on CMS's CY2026 PFS proposed rule
|
||||
(CMS-1832-P, docket CMS-2025-0304), specifically regarding the proposal to
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"""Tests for pfs.nprm — NPRM Addendum B loader → pfs.rvu_proposed (#592)."""
|
||||
"""Tests for pfs.nprm — NPRM Addendum B loader → pfs.rvu_proposed (#592, #612)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import duckdb
|
||||
import pytest
|
||||
|
||||
from pfs import nprm
|
||||
|
||||
|
||||
def _fixture(tmp_path):
|
||||
p = tmp_path / "CY2026_NPRM_Addendum_B.csv"
|
||||
def _fixture(tmp_path, name="CY2026_NPRM_Addendum_B.csv"):
|
||||
p = tmp_path / name
|
||||
p.write_text(
|
||||
"HCPCS,MOD,DESCRIPTION,STATUS CODE,WORK RVU,"
|
||||
"NON-FAC PE RVU,FAC PE RVU,MP RVU\n"
|
||||
@@ -18,20 +19,135 @@ def _fixture(tmp_path):
|
||||
return p
|
||||
|
||||
|
||||
def _fixture_2027(tmp_path):
|
||||
"""CY2027 partition with a distinct HCPCS set so the two are distinguishable."""
|
||||
p = tmp_path / "CY2027_NPRM_Addendum_B.csv"
|
||||
p.write_text(
|
||||
"HCPCS,MOD,DESCRIPTION,STATUS CODE,WORK RVU,"
|
||||
"NON-FAC PE RVU,FAC PE RVU,MP RVU\n"
|
||||
"99214,,Office visit est L4,A,1.9,2.1,0.75,0.15\n"
|
||||
"0002A,,Admin covid dose 2,X,0.0,0.0,0.0,0.0\n"
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def _patch_discovery(monkeypatch, mapping):
|
||||
"""monkeypatch._discover_addendum_b(tag) -> path, from a {tag: path} dict."""
|
||||
|
||||
def _fake(tag):
|
||||
try:
|
||||
return mapping[tag]
|
||||
except KeyError:
|
||||
raise FileNotFoundError(
|
||||
f"No Addendum B attachment found among bib items tagged {tag!r}"
|
||||
) from None
|
||||
|
||||
monkeypatch.setattr(nprm, "_discover_addendum_b", _fake)
|
||||
|
||||
|
||||
def test_load_rvu_proposed_loads_and_reloads(tmp_path, monkeypatch):
|
||||
path = _fixture(tmp_path)
|
||||
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path)
|
||||
_patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
|
||||
con = duckdb.connect()
|
||||
out = nprm.load_rvu_proposed(con)
|
||||
out = nprm.load_rvu_proposed(con, year=2026)
|
||||
assert out["rows"] == 2
|
||||
assert out["cms_rule_id"] == "CMS-1832-P"
|
||||
row = con.execute(
|
||||
"SELECT hcpcs, work_rvu, cms_rule_id FROM pfs.rvu_proposed WHERE hcpcs='99213'"
|
||||
).fetchone()
|
||||
assert row == ("99213", 1.3, "CMS-1832-P")
|
||||
nprm.load_rvu_proposed(con) # idempotent delete-and-reload
|
||||
nprm.load_rvu_proposed(con, year=2026) # idempotent delete-and-reload
|
||||
assert con.execute("SELECT count(*) FROM pfs.rvu_proposed").fetchone()[0] == 2
|
||||
|
||||
|
||||
def test_nprm_sources_covers_both_years():
|
||||
years = {source[0] for source in nprm.NPRM_SOURCES}
|
||||
assert years == {2026, 2027}
|
||||
|
||||
|
||||
def test_load_rvu_proposed_two_partitions_side_by_side(tmp_path, monkeypatch):
|
||||
"""Loading both years leaves both rule partitions intact side by side."""
|
||||
path_2026 = _fixture(tmp_path)
|
||||
path_2027 = _fixture_2027(tmp_path)
|
||||
_patch_discovery(
|
||||
monkeypatch,
|
||||
{"sup:2026_PFS_NPRM": path_2026, "sup:2027_PFS_NPRM": path_2027},
|
||||
)
|
||||
con = duckdb.connect()
|
||||
nprm.load_rvu_proposed(con, year=2026)
|
||||
nprm.load_rvu_proposed(con, year=2027)
|
||||
|
||||
counts = dict(
|
||||
con.execute(
|
||||
"SELECT cms_rule_id, count(*) FROM pfs.rvu_proposed GROUP BY 1"
|
||||
).fetchall()
|
||||
)
|
||||
assert counts == {"CMS-1832-P": 2, "CMS-1848-P": 2}
|
||||
|
||||
|
||||
def test_load_rvu_proposed_reload_one_leaves_other_intact(tmp_path, monkeypatch):
|
||||
"""Reloading the 2026 partition doesn't touch the 2027 partition's rows."""
|
||||
path_2026 = _fixture(tmp_path)
|
||||
path_2027 = _fixture_2027(tmp_path)
|
||||
_patch_discovery(
|
||||
monkeypatch,
|
||||
{"sup:2026_PFS_NPRM": path_2026, "sup:2027_PFS_NPRM": path_2027},
|
||||
)
|
||||
con = duckdb.connect()
|
||||
nprm.load_rvu_proposed(con, year=2026)
|
||||
nprm.load_rvu_proposed(con, year=2027)
|
||||
nprm.load_rvu_proposed(con, year=2026) # reload just 2026
|
||||
|
||||
counts = dict(
|
||||
con.execute(
|
||||
"SELECT cms_rule_id, count(*) FROM pfs.rvu_proposed GROUP BY 1"
|
||||
).fetchall()
|
||||
)
|
||||
assert counts == {"CMS-1832-P": 2, "CMS-1848-P": 2}
|
||||
row_2027 = con.execute(
|
||||
"SELECT hcpcs, work_rvu FROM pfs.rvu_proposed WHERE cms_rule_id='CMS-1848-P' "
|
||||
"AND hcpcs='99214'"
|
||||
).fetchone()
|
||||
assert row_2027 == ("99214", 1.9)
|
||||
|
||||
|
||||
def test_load_rvu_proposed_missing_tag_raises_naming_it(tmp_path, monkeypatch):
|
||||
"""Discovery failure names the missing tag in the error."""
|
||||
_patch_discovery(monkeypatch, {}) # no tags registered at all
|
||||
con = duckdb.connect()
|
||||
with pytest.raises(FileNotFoundError, match="sup:2026_PFS_NPRM"):
|
||||
nprm.load_rvu_proposed(con, year=2026)
|
||||
|
||||
|
||||
def test_load_rvu_proposed_unknown_year_raises():
|
||||
con = duckdb.connect()
|
||||
with pytest.raises(ValueError, match="2099"):
|
||||
nprm.load_rvu_proposed(con, year=2099)
|
||||
|
||||
|
||||
def test_load_rvu_proposed_drops_and_recreates_stale_schema(tmp_path, monkeypatch):
|
||||
"""A pre-existing table with old column names is dropped and rebuilt.
|
||||
|
||||
Regression guard for the pre-#612 table (columns ``modifier`` /
|
||||
``nonfac_pe_rvu`` instead of ``mod`` / ``non_fac_pe_rvu``) — CREATE
|
||||
TABLE IF NOT EXISTS alone won't fix a live mismatched table.
|
||||
"""
|
||||
path = _fixture(tmp_path)
|
||||
_patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
|
||||
con = duckdb.connect()
|
||||
con.execute("CREATE SCHEMA IF NOT EXISTS pfs")
|
||||
con.execute(
|
||||
"CREATE TABLE pfs.rvu_proposed ("
|
||||
"hcpcs VARCHAR, modifier VARCHAR, description VARCHAR, "
|
||||
"status_code VARCHAR, work_rvu DOUBLE, nonfac_pe_rvu DOUBLE, "
|
||||
"fac_pe_rvu DOUBLE, mp_rvu DOUBLE, cms_rule_id VARCHAR)"
|
||||
)
|
||||
out = nprm.load_rvu_proposed(con, year=2026)
|
||||
assert out["rows"] == 2
|
||||
cols = [r[0] for r in con.execute("DESCRIBE pfs.rvu_proposed").fetchall()]
|
||||
assert cols == nprm._TARGET_COLUMNS
|
||||
|
||||
|
||||
def _dirty_fixture(tmp_path):
|
||||
"""Real-file quirks: padded cells, "NA" markers, a footnote row.
|
||||
|
||||
@@ -55,11 +171,11 @@ def _dirty_fixture(tmp_path):
|
||||
def test_load_rvu_proposed_strips_padding(tmp_path, monkeypatch):
|
||||
"""Whitespace-padded HCPCS/numeric cells load clean, not null."""
|
||||
path = _dirty_fixture(tmp_path)
|
||||
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path)
|
||||
_patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
|
||||
con = duckdb.connect()
|
||||
nprm.load_rvu_proposed(con)
|
||||
nprm.load_rvu_proposed(con, year=2026)
|
||||
row = con.execute(
|
||||
"SELECT hcpcs, description, work_rvu, nonfac_pe_rvu, fac_pe_rvu, mp_rvu "
|
||||
"SELECT hcpcs, description, work_rvu, non_fac_pe_rvu, fac_pe_rvu, mp_rvu "
|
||||
"FROM pfs.rvu_proposed WHERE hcpcs='99213'"
|
||||
).fetchone()
|
||||
assert row == ("99213", "Office visit est", 1.3, 1.5, 0.55, 0.1)
|
||||
@@ -68,11 +184,11 @@ def test_load_rvu_proposed_strips_padding(tmp_path, monkeypatch):
|
||||
def test_load_rvu_proposed_na_marker_becomes_null(tmp_path, monkeypatch):
|
||||
""" "NA" in a numeric cell loads as SQL NULL, not a cast error."""
|
||||
path = _dirty_fixture(tmp_path)
|
||||
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path)
|
||||
_patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
|
||||
con = duckdb.connect()
|
||||
nprm.load_rvu_proposed(con)
|
||||
nprm.load_rvu_proposed(con, year=2026)
|
||||
row = con.execute(
|
||||
"SELECT work_rvu, nonfac_pe_rvu, fac_pe_rvu, mp_rvu "
|
||||
"SELECT work_rvu, non_fac_pe_rvu, fac_pe_rvu, mp_rvu "
|
||||
"FROM pfs.rvu_proposed WHERE hcpcs='99214'"
|
||||
).fetchone()
|
||||
assert row == (None, None, None, None)
|
||||
@@ -81,9 +197,9 @@ def test_load_rvu_proposed_na_marker_becomes_null(tmp_path, monkeypatch):
|
||||
def test_load_rvu_proposed_drops_footnote_rows(tmp_path, monkeypatch):
|
||||
"""A footnote row (first cell = long sentence) is not a HCPCS code."""
|
||||
path = _dirty_fixture(tmp_path)
|
||||
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path)
|
||||
_patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
|
||||
con = duckdb.connect()
|
||||
out = nprm.load_rvu_proposed(con)
|
||||
out = nprm.load_rvu_proposed(con, year=2026)
|
||||
assert out["rows"] == 2 # only 99213 and 99214 — footnote row excluded
|
||||
assert con.execute("SELECT count(*) FROM pfs.rvu_proposed").fetchone()[0] == 2
|
||||
footnote = con.execute(
|
||||
|
||||
@@ -5,13 +5,23 @@ Covers every line of pfs/rules/__init__.py:
|
||||
- RULES dict: years 2014-2026 (13 entries), all RuleYear instances
|
||||
- Convenience aliases: cy2024, cy2025, cy2026
|
||||
- Specific parameter values for spot-checked years
|
||||
- PROPOSED registry (final-less years) and proposed_for() helper
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from pfs.rules import RULES, ProposedRule, RuleYear, cy2024, cy2025, cy2026
|
||||
from pfs.rules import (
|
||||
PROPOSED,
|
||||
RULES,
|
||||
ProposedRule,
|
||||
RuleYear,
|
||||
cy2024,
|
||||
cy2025,
|
||||
cy2026,
|
||||
proposed_for,
|
||||
)
|
||||
|
||||
# ── RuleYear model ───────────────────────────────────────────────
|
||||
|
||||
@@ -296,3 +306,88 @@ class TestProposedRule:
|
||||
assert p.anesthesia_cf_qp is None
|
||||
assert p.budget_neutrality_adjustor == 1.0
|
||||
assert p.telehealth_originating_site_fee is None
|
||||
|
||||
|
||||
# ── PROPOSED registry + proposed_for() ───────────────────────────
|
||||
|
||||
|
||||
class TestProposedForHelper:
|
||||
"""proposed_for() resolution across RULES[year].proposed and PROPOSED."""
|
||||
|
||||
def test_resolves_from_rules_when_year_present(self):
|
||||
# 2026 is in RULES and has a .proposed block — proposed_for
|
||||
# should return that same object, not consult PROPOSED.
|
||||
assert proposed_for(2026) is RULES[2026].proposed
|
||||
|
||||
def test_resolves_from_proposed_registry_when_year_absent_from_rules(self):
|
||||
# 2027 has no Final Rule yet — no RULES[2027] — so proposed_for
|
||||
# falls back to the PROPOSED registry.
|
||||
assert 2027 not in RULES
|
||||
assert proposed_for(2027) is PROPOSED[2027]
|
||||
|
||||
def test_missing_year_returns_none(self):
|
||||
assert proposed_for(1999) is None
|
||||
assert 1999 not in RULES
|
||||
assert 1999 not in PROPOSED
|
||||
|
||||
def test_rules_year_without_proposed_block_returns_none(self):
|
||||
# 2025 is in RULES but has no .proposed block.
|
||||
assert RULES[2025].proposed is None
|
||||
assert proposed_for(2025) is None
|
||||
|
||||
def test_2027_in_proposed_not_rules(self):
|
||||
assert 2027 in PROPOSED
|
||||
assert 2027 not in RULES
|
||||
with pytest.raises(KeyError):
|
||||
RULES[2027] # noqa: B018
|
||||
|
||||
|
||||
class TestCy2027Proposed:
|
||||
"""PROPOSED[2027] — CY2027 NPRM (CMS-1848-P) transcribed values.
|
||||
|
||||
Source: data/fr_downloads/2026-14327.txt, 91 FR 43842, published
|
||||
2026-07-16. Per :pincite:`5ITGVDJV`.
|
||||
"""
|
||||
|
||||
def test_identity_fields(self):
|
||||
from datetime import date
|
||||
|
||||
p = PROPOSED[2027]
|
||||
assert p.cms_rule_id == "CMS-1848-P"
|
||||
assert p.fr_document_number == "2026-14327"
|
||||
assert p.federal_register_citation == "91 FR 43842"
|
||||
assert p.published == date(2026, 7, 16)
|
||||
|
||||
def test_comment_close_date(self):
|
||||
from datetime import date
|
||||
|
||||
# DATES block, file lines 108-109: "comments must be received
|
||||
# ... by September 14, 2026."
|
||||
assert PROPOSED[2027].comment_close == date(2026, 9, 14)
|
||||
assert PROPOSED[2027].comment_close > PROPOSED[2027].published
|
||||
|
||||
def test_standard_conversion_factors(self):
|
||||
p = PROPOSED[2027]
|
||||
# RIA detailed derivation, file lines ~39095-39102 (91 FR 44242).
|
||||
assert p.cf_qp == 33.1693 # qualifying APM
|
||||
assert p.conversion_factor == 32.8409 # nonqualifying APM
|
||||
|
||||
def test_anesthesia_conversion_factors(self):
|
||||
p = PROPOSED[2027]
|
||||
assert p.anesthesia_cf_qp == 20.4165 # qualifying APM
|
||||
assert p.anesthesia_cf == 20.2143 # nonqualifying APM
|
||||
|
||||
def test_budget_neutrality_adjustor(self):
|
||||
# "0.53 percent positive budget neutrality adjustment"
|
||||
assert PROPOSED[2027].budget_neutrality_adjustor == 1.0053
|
||||
|
||||
def test_telehealth_originating_site_fee(self):
|
||||
# HCPCS Q3014, file line ~2261 (91 FR 43843): "$32.65"
|
||||
assert PROPOSED[2027].telehealth_originating_site_fee == 32.65
|
||||
|
||||
def test_not_equal_to_cy2026_final_or_proposed(self):
|
||||
p2027 = PROPOSED[2027]
|
||||
p2026 = RULES[2026].proposed
|
||||
assert p2027.conversion_factor != RULES[2026].conversion_factor
|
||||
assert p2027.conversion_factor != p2026.conversion_factor
|
||||
assert p2027.cf_qp != p2026.cf_qp
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
Covers src/qpp/__init__.py:
|
||||
- QpThresholds / RiskStandards / QppProposed model construction
|
||||
- QppYear model construction with required and default fields
|
||||
- QPP dict: performance years 2023-2026, payment_year = performance_year + 2
|
||||
- QPP dict: performance years 2023-2027, payment_year = performance_year + 2
|
||||
- qp_cf_applies: False before performance year 2024, True from 2024 onward
|
||||
- Every entry has a non-empty citation
|
||||
- QPP[2026].proposed is CMS-1832-P
|
||||
- QPP[2026].proposed is CMS-1832-P; QPP[2027].proposed is CMS-1848-P
|
||||
- for_payment_year(year) lookup, including KeyError on miss
|
||||
- Exact-value asserts for transcribed thresholds (see task-6-report.md)
|
||||
- Exact-value asserts for transcribed thresholds (see task-6-report.md,
|
||||
task-7-report.md)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -110,10 +111,10 @@ class TestQppYear:
|
||||
|
||||
|
||||
class TestQppDict:
|
||||
EXPECTED_YEARS = [2023, 2024, 2025, 2026]
|
||||
EXPECTED_YEARS = [2023, 2024, 2025, 2026, 2027]
|
||||
|
||||
def test_count(self):
|
||||
assert len(QPP) == 4
|
||||
assert len(QPP) == 5
|
||||
|
||||
def test_keys(self):
|
||||
assert sorted(QPP.keys()) == self.EXPECTED_YEARS
|
||||
@@ -150,9 +151,17 @@ class TestQpCfApplies:
|
||||
def test_2026_true(self):
|
||||
assert QPP[2026].qp_cf_applies is True
|
||||
|
||||
def test_2027_true(self):
|
||||
assert QPP[2027].qp_cf_applies is True
|
||||
|
||||
|
||||
class TestApmIncentivePct:
|
||||
"""Lump-sum APM Incentive Payment sunsets after payment year 2026."""
|
||||
"""Lump-sum APM Incentive Payment: 5% (2019-2024), 3.5% (2025),
|
||||
1.88% (2026), then a gap for 2027 — but NOT a clean sunset: the
|
||||
Consolidated Appropriations Act, 2026 (CAA 2026) revived a 3.1%
|
||||
payment for payment year 2028 only (42 CFR 414.1450(b)(1)(iv), as
|
||||
amended by CMS-1848-P, 91 FR 44286), before the gap resumes for
|
||||
2029."""
|
||||
|
||||
def test_2023_incentive(self):
|
||||
assert QPP[2023].apm_incentive_pct == 3.5
|
||||
@@ -163,8 +172,22 @@ class TestApmIncentivePct:
|
||||
def test_2025_no_incentive(self):
|
||||
assert QPP[2025].apm_incentive_pct is None
|
||||
|
||||
def test_2026_no_incentive(self):
|
||||
assert QPP[2026].apm_incentive_pct is None
|
||||
def test_2026_incentive_revived_by_caa_2026(self):
|
||||
# Payment year 2028: CAA 2026 revived a 3.1% APM Incentive
|
||||
# Payment — 42 CFR 414.1450(b)(1)(iv), as amended by
|
||||
# CMS-1848-P, 91 FR 44286 (data/fr_downloads/
|
||||
# 2026-14327.txt:42632, "(iv) 2028: 3.1 percent."); narrative
|
||||
# at 91 FR 44218-44219 (2026-14327.txt:37389-37391, 37436).
|
||||
# This supersedes the CY2026 Final Rule's apm_incentive_pct
|
||||
# = None (90 FR 49838/49980).
|
||||
assert QPP[2026].apm_incentive_pct == 3.1
|
||||
|
||||
def test_2027_no_incentive(self):
|
||||
# Payment year 2029: 42 CFR 414.1450(b)(1) (as amended by
|
||||
# CMS-1848-P, 91 FR 44286) lists applicable percentages only
|
||||
# for payment years 2019-2024, 2025, 2026, and 2028 — no entry
|
||||
# for 2029.
|
||||
assert QPP[2027].apm_incentive_pct is None
|
||||
|
||||
|
||||
# ── QPP[2026].proposed — CMS-1832-P ──────────────────────────────
|
||||
@@ -186,25 +209,75 @@ class TestProposed:
|
||||
assert QPP[2025].proposed is None
|
||||
|
||||
|
||||
# ── QPP[2027].proposed — CMS-1848-P ──────────────────────────────
|
||||
|
||||
|
||||
class TestProposed2027:
|
||||
def test_2027_has_proposed(self):
|
||||
p = QPP[2027].proposed
|
||||
assert p is not None
|
||||
assert p.cms_rule_id == "CMS-1848-P"
|
||||
assert p.federal_register_citation == "91 FR 43842"
|
||||
|
||||
def test_2027_changes_non_empty(self):
|
||||
assert len(QPP[2027].proposed.changes) > 0
|
||||
|
||||
def test_2027_no_numeric_change_blocks(self):
|
||||
# CMS-1848-P's threshold/incentive amendments conform the
|
||||
# regulation text to already-enacted statutory levels rather
|
||||
# than proposing new CMS-set numeric values — see
|
||||
# QppProposed.qp_thresholds docstring.
|
||||
p = QPP[2027].proposed
|
||||
assert p.qp_thresholds is None
|
||||
assert p.partial_qp_thresholds is None
|
||||
assert p.risk_standards is None
|
||||
|
||||
|
||||
# ── Exact transcribed threshold values ───────────────────────────
|
||||
|
||||
|
||||
class TestSpecificValues:
|
||||
"""Verify transcribed QP/Partial QP thresholds and risk standards.
|
||||
|
||||
See task-6-report.md transcription table for sourcing per value."""
|
||||
Thresholds are keyed to PAYMENT YEAR by 42 CFR 414.1430(a), not QP
|
||||
Performance Period — see module docstring "Payment-year keying
|
||||
correction" (task #620). Payment years 2021-2026 (QPP[2023] pay
|
||||
2025, QPP[2024] pay 2026) and 2028 (QPP[2026], restored by CAA
|
||||
2026) sit at 50%/35% QP, 40%/25% Partial QP; payment years 2027
|
||||
and 2029+ (QPP[2025], QPP[2027]) sit at 75%/50% QP, 50%/35%
|
||||
Partial QP. See task-6-report.md / task-7-report.md for the
|
||||
original transcription tables and task-620-report.md for the
|
||||
correction."""
|
||||
|
||||
@pytest.mark.parametrize("year", [2023, 2024, 2025, 2026])
|
||||
def test_qp_thresholds(self, year):
|
||||
@pytest.mark.parametrize("year", [2025, 2027])
|
||||
def test_qp_thresholds_75_50(self, year):
|
||||
assert QPP[year].qp_thresholds.payment_amount_pct == 75.0
|
||||
assert QPP[year].qp_thresholds.patient_count_pct == 50.0
|
||||
|
||||
@pytest.mark.parametrize("year", [2023, 2024, 2025, 2026])
|
||||
def test_partial_qp_thresholds(self, year):
|
||||
@pytest.mark.parametrize("year", [2023, 2024, 2026])
|
||||
def test_qp_thresholds_50_35(self, year):
|
||||
# 42 CFR 414.1430(a)(1)(ii)/(a)(3)(ii) "2021 through 2026: 50
|
||||
# percent" / "35 percent" (91 FR 44285); (a)(1)(iv)/(a)(3)(iv)
|
||||
# "2028: 50 percent" / "35 percent" (91 FR 44286) — CAA 2026
|
||||
# for QPP[2026]/payment year 2028.
|
||||
assert QPP[year].qp_thresholds.payment_amount_pct == 50.0
|
||||
assert QPP[year].qp_thresholds.patient_count_pct == 35.0
|
||||
|
||||
@pytest.mark.parametrize("year", [2025, 2027])
|
||||
def test_partial_qp_thresholds_50_35(self, year):
|
||||
assert QPP[year].partial_qp_thresholds.payment_amount_pct == 50.0
|
||||
assert QPP[year].partial_qp_thresholds.patient_count_pct == 35.0
|
||||
|
||||
@pytest.mark.parametrize("year", [2023, 2024, 2025, 2026])
|
||||
@pytest.mark.parametrize("year", [2023, 2024, 2026])
|
||||
def test_partial_qp_thresholds_40_25(self, year):
|
||||
# 42 CFR 414.1430(a)(2)(ii)/(a)(4)(ii) "2021 through 2026: 40
|
||||
# percent" / "25 percent" (91 FR 44285-44286); (a)(2)(iv)/
|
||||
# (a)(4)(iv) "2028: 40 percent" / "25 percent" (91 FR 44286) —
|
||||
# CAA 2026 for QPP[2026]/payment year 2028.
|
||||
assert QPP[year].partial_qp_thresholds.payment_amount_pct == 40.0
|
||||
assert QPP[year].partial_qp_thresholds.patient_count_pct == 25.0
|
||||
|
||||
@pytest.mark.parametrize("year", [2023, 2024, 2025, 2026, 2027])
|
||||
def test_risk_standards(self, year):
|
||||
# Revenue-based standard (42 CFR 414.1415(c)(3)(i)(A)) is
|
||||
# transcribed (8%); the benchmark-based standard (42 CFR
|
||||
@@ -214,7 +287,7 @@ class TestSpecificValues:
|
||||
assert QPP[year].risk_standards.revenue_nominal_pct == 8.0
|
||||
assert QPP[year].risk_standards.benchmark_nominal_pct is None
|
||||
|
||||
@pytest.mark.parametrize("year", [2023, 2024, 2025, 2026])
|
||||
@pytest.mark.parametrize("year", [2023, 2024, 2025, 2026, 2027])
|
||||
def test_cehrt_required(self, year):
|
||||
assert QPP[year].cehrt_required is True
|
||||
|
||||
@@ -235,6 +308,9 @@ class TestForPaymentYear:
|
||||
def test_2028_maps_to_performance_2026(self):
|
||||
assert for_payment_year(2028) is QPP[2026]
|
||||
|
||||
def test_2029_maps_to_performance_2027(self):
|
||||
assert for_payment_year(2029) is QPP[2027]
|
||||
|
||||
def test_unknown_payment_year_raises_keyerror(self):
|
||||
with pytest.raises(KeyError):
|
||||
for_payment_year(1999)
|
||||
|
||||
@@ -158,6 +158,12 @@ def test_docket_rules_maps_cy2026_pfs():
|
||||
assert DOCKET_RULES["CMS-2025-0304"] == "CMS-1832-P"
|
||||
|
||||
|
||||
def test_docket_rules_maps_cy2027_pfs():
|
||||
from rex.comments.classify import DOCKET_RULES
|
||||
|
||||
assert DOCKET_RULES["CMS-2026-2377"] == "CMS-1848-P"
|
||||
|
||||
|
||||
def test_system_prompt_names_the_pfs_rule():
|
||||
from rex.comments.classify import _SYSTEM
|
||||
|
||||
|
||||
Reference in New Issue
Block a user