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
|
Downloads a CY{year} PFS Proposed Rule "Addenda" ZIP, which contains
|
||||||
contains Addendum B (proposed RVUs by HCPCS), and optionally registers
|
Addendum B (proposed RVUs by HCPCS), and optionally registers Addendum B
|
||||||
Addendum B in bib against the CY2026 PFS NPRM rule item (2KVJ2HKX),
|
in bib against that year's NPRM rule item, tagged ``sup:{year}_PFS_NPRM``
|
||||||
tagged ``sup:2026_PFS_NPRM`` so Task 5's loader can discover the
|
so the loader can discover the *proposed* Addendum B without touching
|
||||||
*proposed* Addendum B without touching final-rule files (which carry
|
final-rule files (which carry ``sup:{year}_PFS_FR`` — the two namespaces
|
||||||
``sup:2026_PFS_FR`` — the two namespaces must never collide).
|
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).
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run python dev/scripts/fetch_nprm_addenda.py --dry-run
|
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 --year 2027
|
||||||
uv run python dev/scripts/fetch_nprm_addenda.py --register
|
uv run python dev/scripts/fetch_nprm_addenda.py --year 2027 --register
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -27,36 +18,87 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import re
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
DEST_DIR = ROOT / "data" / "cms" / "pfs_nprm" / "2026"
|
|
||||||
|
|
||||||
USER_AGENT = (
|
USER_AGENT = (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/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")
|
ADDENDUM_B_RE = re.compile(r"(?i)addendum[_ ]?b")
|
||||||
|
|
||||||
RULE_ITEM_KEY = "2KVJ2HKX"
|
|
||||||
ADDENDUM_B_TITLE = "CY2026 PFS NPRM Addendum B (proposed RVUs)"
|
@dataclass(frozen=True)
|
||||||
REGISTER_TAGS = ["sup:2026_PFS_NPRM", "module:pfs", "file:rvu", "year:2026"]
|
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."""
|
"""Download the addenda ZIP if it doesn't already exist."""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -67,7 +109,7 @@ def download_zip(dest: Path, *, retries: int = 3) -> bool:
|
|||||||
for attempt in range(1, retries + 1):
|
for attempt in range(1, retries + 1):
|
||||||
try:
|
try:
|
||||||
resp = httpx.get(
|
resp = httpx.get(
|
||||||
ADDENDA_URL,
|
url,
|
||||||
headers={"User-Agent": USER_AGENT},
|
headers={"User-Agent": USER_AGENT},
|
||||||
timeout=120,
|
timeout=120,
|
||||||
follow_redirects=True,
|
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:
|
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 = [
|
candidates = [
|
||||||
f
|
f
|
||||||
for f in files
|
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:
|
if not candidates:
|
||||||
return None
|
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
|
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.
|
"""Attach Addendum B to the NPRM rule item and tag it.
|
||||||
|
|
||||||
Idempotent: if an attachment with the Addendum B title already exists
|
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()
|
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:
|
if existing_key is not None:
|
||||||
print(
|
print(
|
||||||
f"SKIP attach — {RULE_ITEM_KEY} already has an attachment titled "
|
f"SKIP attach — {cfg.rule_item_key} already has an attachment "
|
||||||
f"{ADDENDUM_B_TITLE!r} (key {existing_key})"
|
f"titled {cfg.addendum_b_title!r} (key {existing_key})"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
att_key = store.attach_file(
|
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
|
con = store._con() # noqa: SLF001 — same pattern as bib/oig.py, bib/iom.py
|
||||||
row = con.execute(
|
row = con.execute(
|
||||||
@@ -164,16 +215,30 @@ def register(addendum_b_path: Path) -> None:
|
|||||||
storage_path = row["storage_path"] if row else "<unknown>"
|
storage_path = row["storage_path"] if row else "<unknown>"
|
||||||
print(f"Attached {addendum_b_path.name} (key {att_key}) -> {storage_path}")
|
print(f"Attached {addendum_b_path.name} (key {att_key}) -> {storage_path}")
|
||||||
|
|
||||||
for tag in REGISTER_TAGS:
|
for tag in cfg.register_tags:
|
||||||
store.add_tag(RULE_ITEM_KEY, tag)
|
store.add_tag(cfg.rule_item_key, tag)
|
||||||
print(f"Tagged {RULE_ITEM_KEY} with: {', '.join(REGISTER_TAGS)}")
|
print(f"Tagged {cfg.rule_item_key} with: {', '.join(cfg.register_tags)}")
|
||||||
item = store.get(RULE_ITEM_KEY)
|
item = store.get(cfg.rule_item_key)
|
||||||
print(f"Current tags on {RULE_ITEM_KEY}: {item.tags}")
|
print(f"Current tags on {cfg.rule_item_key}: {item.tags}")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
years = sorted(CONFIGS)
|
||||||
parser = argparse.ArgumentParser(
|
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(
|
parser.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
@@ -183,34 +248,35 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--register",
|
"--register",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="After fetching, attach Addendum B to bib item "
|
help="After fetching, attach Addendum B to the rule item and apply "
|
||||||
f"{RULE_ITEM_KEY} and apply the {', '.join(REGISTER_TAGS)} tags.",
|
"its tags for the selected --year.",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
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:
|
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 save {zip_dest}")
|
||||||
print(f"Would extract into {DEST_DIR}")
|
print(f"Would extract into {cfg.dest_dir}")
|
||||||
if args.register:
|
if args.register:
|
||||||
print(
|
print(
|
||||||
f"Would attach Addendum B to {RULE_ITEM_KEY} "
|
f"Would attach Addendum B to {cfg.rule_item_key} "
|
||||||
f"and tag with: {', '.join(REGISTER_TAGS)}"
|
f"and tag with: {', '.join(cfg.register_tags)}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
DEST_DIR.mkdir(parents=True, exist_ok=True)
|
cfg.dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if not download_zip(zip_dest):
|
if not download_zip(cfg.addenda_url, zip_dest):
|
||||||
raise SystemExit(f"Failed to download {ADDENDA_URL}")
|
raise SystemExit(f"Failed to download {cfg.addenda_url}")
|
||||||
|
|
||||||
files = extract_zip(zip_dest, DEST_DIR)
|
files = extract_zip(zip_dest, cfg.dest_dir)
|
||||||
print(f"\nExtracted {len(files)} files to {DEST_DIR}:")
|
print(f"\nExtracted {len(files)} files to {cfg.dest_dir}:")
|
||||||
for f in files:
|
for f in files:
|
||||||
size_kb = f.stat().st_size / 1024
|
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)
|
addendum_b = find_addendum_b(files)
|
||||||
if addendum_b is None:
|
if addendum_b is None:
|
||||||
@@ -218,12 +284,12 @@ def main() -> None:
|
|||||||
"Could not find an Addendum B file (pattern "
|
"Could not find an Addendum B file (pattern "
|
||||||
f"{ADDENDUM_B_RE.pattern!r}) among extracted files."
|
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)
|
preview_addendum_b(addendum_b)
|
||||||
|
|
||||||
if args.register:
|
if args.register:
|
||||||
print()
|
print()
|
||||||
register(addendum_b)
|
register(cfg, addendum_b)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -36,14 +36,16 @@ def main() -> int:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--nprm",
|
"--nprm",
|
||||||
action="store_true",
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
from cms import ingest_log
|
from cms import ingest_log
|
||||||
from conf.connect import duckdb_batch, publish_replica
|
from conf.connect import duckdb_batch, publish_replica
|
||||||
from pfs.pipe import load_all
|
from pfs.pipe import load_all
|
||||||
from pfs.rules import RULES
|
|
||||||
|
|
||||||
run_id = ingest_log.new_run_id()
|
run_id = ingest_log.new_run_id()
|
||||||
|
|
||||||
@@ -62,22 +64,23 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if args.nprm:
|
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) ---")
|
print("\n--- NPRM Addendum B (proposed RVUs) ---")
|
||||||
nprm_out = load_rvu_proposed(con)
|
for year, _tag, cms_rule_id, _pincite_key in NPRM_SOURCES:
|
||||||
print(f" pfs.rvu_proposed : {nprm_out}")
|
nprm_out = load_rvu_proposed(con, year=year)
|
||||||
ingest_log.log_ingest(
|
print(f" pfs.rvu_proposed [{cms_rule_id}]: {nprm_out}")
|
||||||
con,
|
ingest_log.log_ingest(
|
||||||
module="pfs",
|
con,
|
||||||
table_name="pfs.rvu_proposed",
|
module="pfs",
|
||||||
rows=nprm_out["rows"],
|
table_name="pfs.rvu_proposed",
|
||||||
source_file=nprm_out["source_file"],
|
rows=nprm_out["rows"],
|
||||||
rule_id="CMS-1832-P",
|
source_file=nprm_out["source_file"],
|
||||||
fr_citation=RULES[2026].proposed.federal_register_citation,
|
rule_id=nprm_out["cms_rule_id"],
|
||||||
pincite_key="2KVJ2HKX",
|
fr_citation=nprm_out["fr_citation"],
|
||||||
run_id=run_id,
|
pincite_key=nprm_out["pincite_key"],
|
||||||
)
|
run_id=run_id,
|
||||||
|
)
|
||||||
|
|
||||||
replica = publish_replica("aco")
|
replica = publish_replica("aco")
|
||||||
print(f"replica → {replica}")
|
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):
|
def _(q):
|
||||||
rvu_delta_raw = q("""
|
rvu_delta_raw = q("""
|
||||||
WITH proposed AS (
|
WITH proposed AS (
|
||||||
SELECT hcpcs, description, work_rvu, nonfac_pe_rvu, mp_rvu,
|
SELECT hcpcs, description, work_rvu, non_fac_pe_rvu, mp_rvu,
|
||||||
work_rvu + nonfac_pe_rvu + mp_rvu AS total_nf_rvu,
|
work_rvu + non_fac_pe_rvu + mp_rvu AS total_nf_rvu,
|
||||||
CASE
|
CASE
|
||||||
WHEN hcpcs BETWEEN '99201' AND '99499' THEN 'E/M'
|
WHEN hcpcs BETWEEN '99201' AND '99499' THEN 'E/M'
|
||||||
WHEN hcpcs BETWEEN '10000' AND '19999' THEN 'Integumentary'
|
WHEN hcpcs BETWEEN '10000' AND '19999' THEN 'Integumentary'
|
||||||
@@ -295,9 +295,10 @@ def _(q):
|
|||||||
ELSE 'Other'
|
ELSE 'Other'
|
||||||
END AS category
|
END AS category
|
||||||
FROM pfs.rvu_proposed
|
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 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
|
QUALIFY row_number() OVER (PARTITION BY hcpcs ORDER BY hcpcs) = 1
|
||||||
),
|
),
|
||||||
final_2025 AS (
|
final_2025 AS (
|
||||||
@@ -433,13 +434,13 @@ def _(mo, rvu_delta):
|
|||||||
def _(con, mo):
|
def _(con, mo):
|
||||||
_new = con.execute("""
|
_new = con.execute("""
|
||||||
SELECT count(*) FROM pfs.rvu_proposed p
|
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=''))
|
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]
|
""").fetchone()[0]
|
||||||
_dropped = con.execute("""
|
_dropped = con.execute("""
|
||||||
SELECT count(*) FROM pfs.rvu f
|
SELECT count(*) FROM pfs.rvu f
|
||||||
WHERE f.year=2025 AND (f.mod IS NULL OR f.mod='') AND f.status_code='A'
|
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]
|
""").fetchone()[0]
|
||||||
mo.md(f"""
|
mo.md(f"""
|
||||||
> **Coverage caveat:** {_new} HCPCS codes appear in `pfs.rvu_proposed`
|
> **Coverage caveat:** {_new} HCPCS codes appear in `pfs.rvu_proposed`
|
||||||
@@ -603,7 +604,7 @@ def _(QPP, pl):
|
|||||||
def _(alt, apm_thresholds, mo):
|
def _(alt, apm_thresholds, mo):
|
||||||
_df = apm_thresholds.to_pandas()
|
_df = apm_thresholds.to_pandas()
|
||||||
_df["apm_incentive_pct"] = _df["apm_incentive_pct"].fillna(0.0)
|
_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 = (
|
incentive_chart = (
|
||||||
alt.Chart(_df)
|
alt.Chart(_df)
|
||||||
@@ -611,10 +612,14 @@ def _(alt, apm_thresholds, mo):
|
|||||||
.encode(
|
.encode(
|
||||||
x=alt.X("payment_year:O", title="Payment year"),
|
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)"),
|
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"],
|
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"])))
|
_citations = ", ".join(sorted(set(_df["citation"])))
|
||||||
@@ -623,12 +628,18 @@ def _(alt, apm_thresholds, mo):
|
|||||||
[
|
[
|
||||||
incentive_chart,
|
incentive_chart,
|
||||||
mo.md(f"""
|
mo.md(f"""
|
||||||
Payment year 2026 (QP Performance Period 2024) is, under
|
The lump-sum APM Incentive Payment does **not** sunset
|
||||||
current law, the **last** year with a lump-sum APM incentive
|
cleanly after payment year 2026. Payment year 2027 (QP
|
||||||
payment (1.88% of base-year paid claims). From payment year
|
Performance Period 2025) has no incentive under current
|
||||||
2027 onward, QP status stops paying a bonus and starts
|
law. The Consolidated Appropriations Act, 2026 (CAA 2026,
|
||||||
(instead) determining *which conversion factor applies* — see
|
Pub. L. 119-75) then **revives a 3.1% incentive for payment
|
||||||
below.
|
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
|
**Sources:** `qpp.QPP` performance years 2023–2026
|
||||||
({_citations}).
|
({_citations}).
|
||||||
@@ -642,8 +653,21 @@ def _(alt, apm_thresholds, mo):
|
|||||||
def _(QPP, mo):
|
def _(QPP, mo):
|
||||||
_cy = QPP[2026]
|
_cy = QPP[2026]
|
||||||
_proposed = _cy.proposed
|
_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"""
|
mo.md(f"""
|
||||||
### CY2026 NPRM proposed QPP changes — proposed vs. finalized disposition
|
### 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) |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 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`.
|
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
|
Loads a PFS NPRM's Addendum B "Relative Value Units and Related
|
||||||
Information" workbook — registered in bib under the
|
Information" workbook — registered in bib under a per-year
|
||||||
``sup:2026_PFS_NPRM`` tag — into ``pfs.rvu_proposed``, the
|
``sup:<year>_PFS_NPRM`` tag — into ``pfs.rvu_proposed``, the
|
||||||
proposed-rule counterpart to the final-rule ``pfs.rvu`` table that
|
proposed-rule counterpart to the final-rule ``pfs.rvu`` table that
|
||||||
``pfs.pipe.load_all`` populates. Reuses ``pfs.pipe``'s header-detect
|
``pfs.pipe.load_all`` populates. Reuses ``pfs.pipe``'s header-detect
|
||||||
readers and column map instead of reimplementing them; the target
|
readers and column map instead of reimplementing them; the target
|
||||||
schema is intentionally narrower than ``pfs.rvu`` (no PPRRVU-only
|
schema is intentionally narrower than ``pfs.rvu`` (no PPRRVU-only
|
||||||
columns) since Addendum B never carries them.
|
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
|
from __future__ import annotations
|
||||||
@@ -16,29 +21,23 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_ADDENDUM_B_TAG = "sup:2026_PFS_NPRM"
|
|
||||||
_ADDENDUM_B_RE = re.compile(r"(?i)addendum[_ ]?b")
|
_ADDENDUM_B_RE = re.compile(r"(?i)addendum[_ ]?b")
|
||||||
|
|
||||||
# pfs.pipe._RVU_COLUMNS normalizes raw headers to these intermediate
|
# pfs.pipe._RVU_COLUMNS normalizes raw headers to these intermediate
|
||||||
# names; map them onto the pfs.rvu_proposed target schema.
|
# names, which already match the pfs.rvu_proposed target schema (#607
|
||||||
_TARGET_RENAME = {
|
# aligned it with pfs.rvu's column names — no renaming needed here).
|
||||||
"hcpcs": "hcpcs",
|
# The CY2027 Addendum B workbook's headers (CPT1/\nHCPCS, Mod, Status,
|
||||||
"mod": "modifier",
|
# \nWork\nRVUs2, \nNon-\nFacility\nPE\nRVUs2, \nFacility\nPE\nRVUs2,
|
||||||
"description": "description",
|
# Mal-\nPractice\nRVUs2) are already present in _RVU_COLUMNS as
|
||||||
"status_code": "status_code",
|
# variants of the CY2026 PPRRVU headers, so no map changes were needed
|
||||||
"work_rvu": "work_rvu",
|
# for #612 — verified against the real CY2027 file.
|
||||||
"non_fac_pe_rvu": "nonfac_pe_rvu",
|
|
||||||
"fac_pe_rvu": "fac_pe_rvu",
|
|
||||||
"mp_rvu": "mp_rvu",
|
|
||||||
}
|
|
||||||
|
|
||||||
_TARGET_COLUMNS = [
|
_TARGET_COLUMNS = [
|
||||||
"hcpcs",
|
"hcpcs",
|
||||||
"modifier",
|
"mod",
|
||||||
"description",
|
"description",
|
||||||
"status_code",
|
"status_code",
|
||||||
"work_rvu",
|
"work_rvu",
|
||||||
"nonfac_pe_rvu",
|
"non_fac_pe_rvu",
|
||||||
"fac_pe_rvu",
|
"fac_pe_rvu",
|
||||||
"mp_rvu",
|
"mp_rvu",
|
||||||
"cms_rule_id",
|
"cms_rule_id",
|
||||||
@@ -47,13 +46,31 @@ _TARGET_COLUMNS = [
|
|||||||
# Intermediate (pre-rename) names as produced by pfs.pipe._normalize_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")
|
_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.
|
"""Locate the NPRM Addendum B attachment in the bib store.
|
||||||
|
|
||||||
Queries attachments of items tagged ``sup:2026_PFS_NPRM`` for one
|
Queries attachments of items tagged ``tag`` for one whose filename
|
||||||
whose filename matches ``addendum[_ ]?b`` (case-insensitive),
|
matches ``addendum[_ ]?b`` (case-insensitive), mirroring the join
|
||||||
mirroring the join in ``llm/source.py::_attachment_text``.
|
in ``llm/source.py::_attachment_text``.
|
||||||
"""
|
"""
|
||||||
from conf import connect
|
from conf import connect
|
||||||
|
|
||||||
@@ -67,7 +84,7 @@ def _discover_addendum_b() -> Path:
|
|||||||
"JOIN tags t ON t.id = it.tag_id "
|
"JOIN tags t ON t.id = it.tag_id "
|
||||||
"WHERE t.name = ? "
|
"WHERE t.name = ? "
|
||||||
"ORDER BY a.id",
|
"ORDER BY a.id",
|
||||||
(_ADDENDUM_B_TAG,),
|
(tag,),
|
||||||
)
|
)
|
||||||
.fetchall()
|
.fetchall()
|
||||||
)
|
)
|
||||||
@@ -76,17 +93,43 @@ def _discover_addendum_b() -> Path:
|
|||||||
if _ADDENDUM_B_RE.search(filename):
|
if _ADDENDUM_B_RE.search(filename):
|
||||||
return Path(row["storage_path"])
|
return Path(row["storage_path"])
|
||||||
raise FileNotFoundError(
|
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:
|
def _schema_matches_target(con: Any) -> bool:
|
||||||
"""Load the NPRM Addendum B workbook into ``pfs.rvu_proposed``.
|
"""True if ``pfs.rvu_proposed`` doesn't exist yet, or its columns match.
|
||||||
|
|
||||||
Idempotent: deletes any existing rows for ``cms_rule_id`` before
|
False means a stale table (e.g. the pre-#612 ``modifier`` /
|
||||||
inserting, so re-running the ingest is safe.
|
``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
|
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_csv_with_header,
|
||||||
_read_excel,
|
_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()
|
ext = path.suffix.lower()
|
||||||
if ext == ".csv":
|
if ext == ".csv":
|
||||||
df = _read_csv_with_header(str(path))
|
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}$")
|
& 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.with_columns(pl.lit(cms_rule_id).alias("cms_rule_id"))
|
||||||
df = df.select(_TARGET_COLUMNS)
|
df = df.select(_TARGET_COLUMNS)
|
||||||
|
|
||||||
con.execute("CREATE SCHEMA IF NOT EXISTS pfs")
|
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(
|
con.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS pfs.rvu_proposed (
|
CREATE TABLE IF NOT EXISTS pfs.rvu_proposed (
|
||||||
hcpcs VARCHAR,
|
hcpcs VARCHAR,
|
||||||
modifier VARCHAR,
|
mod VARCHAR,
|
||||||
description VARCHAR,
|
description VARCHAR,
|
||||||
status_code VARCHAR,
|
status_code VARCHAR,
|
||||||
work_rvu DOUBLE,
|
work_rvu DOUBLE,
|
||||||
nonfac_pe_rvu DOUBLE,
|
non_fac_pe_rvu DOUBLE,
|
||||||
fac_pe_rvu DOUBLE,
|
fac_pe_rvu DOUBLE,
|
||||||
mp_rvu DOUBLE,
|
mp_rvu DOUBLE,
|
||||||
cms_rule_id VARCHAR
|
cms_rule_id VARCHAR
|
||||||
@@ -163,10 +213,16 @@ def load_rvu_proposed(con: Any, *, cms_rule_id: str = "CMS-1832-P") -> dict:
|
|||||||
try:
|
try:
|
||||||
con.execute(
|
con.execute(
|
||||||
"INSERT INTO pfs.rvu_proposed SELECT "
|
"INSERT INTO pfs.rvu_proposed SELECT "
|
||||||
"hcpcs, modifier, description, status_code, work_rvu, "
|
"hcpcs, mod, description, status_code, work_rvu, "
|
||||||
"nonfac_pe_rvu, fac_pe_rvu, mp_rvu, cms_rule_id FROM _nprm_df"
|
"non_fac_pe_rvu, fac_pe_rvu, mp_rvu, cms_rule_id FROM _nprm_df"
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
con.unregister("_nprm_df")
|
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].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, 2, 1)) # $32.7442 (pre-CAA)
|
||||||
RULES[2024].cf_for_date(date(2024, 6, 1)) # $33.2875 (post-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
|
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 ──────────────────────────────────────────
|
# ── Convenience aliases ──────────────────────────────────────────
|
||||||
|
|
||||||
cy2024 = RULES[2024]
|
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`` /
|
years. See :class:`pfs.rules.RuleYear` (``cf_qp`` /
|
||||||
``conversion_factor`` on ``RULES[2026]``) for the actual dollar CFs.
|
``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
|
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
|
prior-year Part B paid claims) is statutorily set at: 5% for payment
|
||||||
payment years 2019-2024, 3.5% for payment year 2025 (QP Performance
|
years 2019-2024, 3.5% for payment year 2025 (QP Performance Period
|
||||||
Period 2023), 1.88% for payment year 2026 (QP Performance Period
|
2023), 1.88% for payment year 2026 (QP Performance Period 2024).
|
||||||
2024) — and payment year 2026 is, under current law, the *last* year
|
Payment year 2026 was, under prior law, believed the *last* year
|
||||||
these payments are made. ``QppYear.apm_incentive_pct`` is ``None``
|
these payments would be made — ``QppYear.apm_incentive_pct`` is
|
||||||
for QP Performance Periods 2025 and later.
|
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
|
CMS-1832-P (CY2026 PFS proposed rule, 90 FR 32352, transcribed from
|
||||||
``data/fr_downloads/2025-13271.txt``) proposed several QPP
|
``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``
|
number under a different provision's field, ``benchmark_nominal_pct``
|
||||||
is left ``None`` on every entry, with the gap documented in
|
is left ``None`` on every entry, with the gap documented in
|
||||||
``RiskStandards.notes``.
|
``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
|
from __future__ import annotations
|
||||||
@@ -212,9 +361,14 @@ class QppYear(BaseModel):
|
|||||||
|
|
||||||
apm_incentive_pct: float | None
|
apm_incentive_pct: float | None
|
||||||
"""Lump-sum APM Incentive Payment, as a % of the QP's base-year
|
"""Lump-sum APM Incentive Payment, as a % of the QP's base-year
|
||||||
(payment_year - 1) paid claims for covered professional services —
|
(payment_year - 1) paid claims for covered professional services.
|
||||||
``None`` once the statutory sunset takes effect (payment years
|
``None`` for payment years without a payment under current law:
|
||||||
2027+, i.e. performance years 2025+)."""
|
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
|
qp_cf_applies: bool
|
||||||
"""``True`` once the payment year for this performance period uses
|
"""``True`` once the payment year for this performance period uses
|
||||||
@@ -237,60 +391,62 @@ class QppYear(BaseModel):
|
|||||||
|
|
||||||
# ── Annual QPP registry ──────────────────────────────────────────
|
# ── Annual QPP registry ──────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# QP / Partial QP thresholds: the statutory schedule (section
|
# QP / Partial QP thresholds are keyed to PAYMENT YEAR by both the
|
||||||
# 1833(z)(2) of the Act) raised the Medicare-option thresholds to 75%
|
# statute (section 1833(z)(2) of the Act) and 42 CFR 414.1430(a) — NOT
|
||||||
# (payment amount) / 50% (patient count) effective QP Performance
|
# to QP Performance Period. (An earlier version of this module read
|
||||||
# Period 2023, with Partial QP thresholds set at the prior period's
|
# the statutory 75%/50% step-up as keyed to performance period,
|
||||||
# QP levels — 50% / 35%. Transcribed from data/fr_downloads/
|
# shifting it two years too early onto QPP[2023]/QPP[2024]; corrected
|
||||||
# 2025-13271.txt (NPRM) and cross-checked against data/fr_downloads/
|
# under task #620 / issue #613 — see module docstring "Payment-year
|
||||||
# 2025-19787.txt (Final Rule):
|
# 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
|
# payment year QP (amt / cnt) Partial QP (amt / cnt)
|
||||||
# increased relative to previous years and, effective with the
|
# 2021-2026 50% / 35% 40% / 25%
|
||||||
# 2025 QP Performance Period are now at 75 percent for the
|
# 2027 75% / 50% 50% / 35%
|
||||||
# payment amount and 50 percent for the patient count."
|
# 2028 50% / 35% 40% / 25% (CAA 2026 revival)
|
||||||
# - NPRM 90 FR 32819 (line 40647-40656, Regulatory Impact Analysis):
|
# 2029+ 75% / 50% 50% / 35%
|
||||||
# "the thresholds to achieve QP status beginning in the 2026 QP
|
#
|
||||||
# Performance Period ... will increase from 50 percent to 75
|
# Mapped onto this registry's performance-year keys (payment_year =
|
||||||
# percent for the payment amount, and from 35 percent to 50
|
# performance_year + 2):
|
||||||
# percent for the patient count." This RIA sentence is internally
|
#
|
||||||
# inconsistent with the 90 FR 32772 recital above (both appear in
|
# - QPP[2023] (pay 2025), QPP[2024] (pay 2026): 50%/35% QP, 40%/25%
|
||||||
# the same NPRM) — it reads as stale boilerplate describing the
|
# Partial QP — 42 CFR 414.1430(a)(1)(ii)/(a)(2)(ii)/(a)(3)(ii)/
|
||||||
# pre-2023 -> 2023 statutory step-up, not a change CMS-1832-P
|
# (a)(4)(ii), 91 FR 44285-44286; original source 89 FR 98564
|
||||||
# itself proposes.
|
# (CY2024 PFS Final Rule); CAA 2024 Sec. 304 narrative, 91 FR
|
||||||
# - Final Rule 90 FR 49980 (line 69796-69798; corrected from a
|
# 44219.
|
||||||
# mistranscribed "90 FR 49928" — that page marker is at line 64990,
|
# - QPP[2025] (pay 2027), QPP[2027] (pay 2029): 75%/50% QP, 50%/35%
|
||||||
# these lines fall between the [[Page 49980]] marker at line 69792
|
# Partial QP — unchanged; see each entry's own citation below.
|
||||||
# and the [[Page 49981]] marker at line 69913) drops the "increase
|
# - QPP[2026] (pay 2028): 50%/35% QP, 40%/25% Partial QP, plus a
|
||||||
# from" framing entirely: "The thresholds to achieve QP status in
|
# revived 3.1% APM Incentive Payment — CAA 2026, superseding the
|
||||||
# the 2026 QP Performance Period (2028 payment year) are set to
|
# CY2026 Final Rule narrative (90 FR 49980) this module originally
|
||||||
# 75 percent for the payment amount, and 50 percent for the
|
# transcribed; see module docstring "CAA 2026 supersession".
|
||||||
# patient count" — consistent with 75/50 being the unchanged,
|
#
|
||||||
# already-current value, not a new increase.
|
# ``_qp_thresholds_75_50``/``_qp_thresholds_50_35`` and
|
||||||
# - Final Rule 90 FR 49928 (line 65021-65026): "...if the proposed
|
# ``_partial_qp_thresholds_50_35``/``_partial_qp_thresholds_40_25``
|
||||||
# policy would result in an ACO's payment amount score increasing
|
# below supply the two distinct threshold-sets in play across
|
||||||
# from 48 percent to 51 percent, that would cross the 50 percent
|
# 2023-2027; they are no longer a single constant-returning helper
|
||||||
# Partial QP threshold" — confirms Partial QP payment-amount
|
# because the values now differ by payment year.
|
||||||
# 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.
|
|
||||||
#
|
#
|
||||||
# Risk standards: see module docstring "Nominal amount standard
|
# Risk standards: see module docstring "Nominal amount standard
|
||||||
# sourcing caveat" — 8% revenue-based (42 CFR 414.1415(c)(3)(i)(A)),
|
# sourcing caveat" — 8% revenue-based (42 CFR 414.1415(c)(3)(i)(A)),
|
||||||
# unchanged across 2023-2026 (no proposed or finalized change found in
|
# unchanged across 2023-2027 (no proposed or finalized change found in
|
||||||
# either CY2026 document). Benchmark-based standard (42 CFR
|
# any FR document reviewed). Benchmark-based standard (42 CFR
|
||||||
# 414.1415(c)(3)(i)(B)) left None — not stated as a bare number
|
# 414.1415(c)(3)(i)(B)) left None — not stated as a bare number
|
||||||
# anywhere in this repository's FR corpus.
|
# 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
|
# 32818 / Final Rule 90 FR 49979-49980 ("b. APM Incentive Payments to
|
||||||
# QPs in Advanced APMs and Other Payer Advanced APMs"), and the
|
# QPs in Advanced APMs and Other Payer Advanced APMs"), and the
|
||||||
# sunset statement at NPRM 90 FR 32696 / Final Rule 90 FR 49838
|
# sunset statement at NPRM 90 FR 32696 / Final Rule 90 FR 49838
|
||||||
# ("Under current law, payment year 2026 is the last year for these
|
# ("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 = (
|
_RISK_NOTES = (
|
||||||
"Revenue-based 8% per CY2019 PFS Final Rule (83 FR 59923, "
|
"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)
|
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)
|
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] = {
|
QPP: dict[int, QppYear] = {
|
||||||
2023: 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,
|
performance_year=2023,
|
||||||
payment_year=2025,
|
payment_year=2025,
|
||||||
qp_thresholds=_qp_thresholds(),
|
qp_thresholds=_qp_thresholds_50_35(),
|
||||||
partial_qp_thresholds=_partial_qp_thresholds(),
|
partial_qp_thresholds=_partial_qp_thresholds_40_25(),
|
||||||
risk_standards=_risk_standards(),
|
risk_standards=_risk_standards(),
|
||||||
apm_incentive_pct=3.5,
|
apm_incentive_pct=3.5,
|
||||||
qp_cf_applies=False,
|
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(
|
2024: QppYear(
|
||||||
# Payment year 2026 is the first year with a differential
|
# Payment year 2026 is the first year with a differential
|
||||||
# QP/non-QP conversion factor (section 1848(d)(20) of the
|
# QP/non-QP conversion factor (section 1848(d)(20) of the
|
||||||
# Act) AND still receives the (final year of) lump-sum APM
|
# Act) AND is the last year of the original uninterrupted
|
||||||
# Incentive Payment — see NPRM 90 FR 32818, Final Rule 90 FR
|
# 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.
|
# 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,
|
performance_year=2024,
|
||||||
payment_year=2026,
|
payment_year=2026,
|
||||||
qp_thresholds=_qp_thresholds(),
|
qp_thresholds=_qp_thresholds_50_35(),
|
||||||
partial_qp_thresholds=_partial_qp_thresholds(),
|
partial_qp_thresholds=_partial_qp_thresholds_40_25(),
|
||||||
risk_standards=_risk_standards(),
|
risk_standards=_risk_standards(),
|
||||||
apm_incentive_pct=1.88,
|
apm_incentive_pct=1.88,
|
||||||
qp_cf_applies=True,
|
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(
|
2025: QppYear(
|
||||||
# Under current law, payment year 2026 (performance year
|
# Under prior law, payment year 2026 (performance year 2024)
|
||||||
# 2024) was the last year with a lump-sum APM Incentive
|
# was believed the last year with a lump-sum APM Incentive
|
||||||
# Payment — NPRM 90 FR 32696, Final Rule 90 FR 49838.
|
# 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,
|
performance_year=2025,
|
||||||
payment_year=2027,
|
payment_year=2027,
|
||||||
qp_thresholds=_qp_thresholds(),
|
qp_thresholds=_qp_thresholds_75_50(),
|
||||||
partial_qp_thresholds=_partial_qp_thresholds(),
|
partial_qp_thresholds=_partial_qp_thresholds_50_35(),
|
||||||
risk_standards=_risk_standards(),
|
risk_standards=_risk_standards(),
|
||||||
apm_incentive_pct=None,
|
apm_incentive_pct=None,
|
||||||
qp_cf_applies=True,
|
qp_cf_applies=True,
|
||||||
citation="90 FR 49838",
|
citation="90 FR 49838",
|
||||||
),
|
),
|
||||||
2026: QppYear(
|
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,
|
performance_year=2026,
|
||||||
payment_year=2028,
|
payment_year=2028,
|
||||||
qp_thresholds=_qp_thresholds(),
|
qp_thresholds=_qp_thresholds_50_35(),
|
||||||
partial_qp_thresholds=_partial_qp_thresholds(),
|
partial_qp_thresholds=_partial_qp_thresholds_40_25(),
|
||||||
risk_standards=_risk_standards(),
|
risk_standards=_risk_standards(),
|
||||||
apm_incentive_pct=None,
|
apm_incentive_pct=3.1,
|
||||||
qp_cf_applies=True,
|
qp_cf_applies=True,
|
||||||
# Corrected from "90 FR 49928" alone: the QP threshold statement
|
citation=(
|
||||||
# itself is at 90 FR 49980 (see docstring pincite above); 49928
|
"90 FR 49928, 49980 (CY2026 Final Rule narrative — "
|
||||||
# is retained alongside it since it's independently the source
|
"accurate when published, since SUPERSEDED); corrected "
|
||||||
# for the Partial QP payment-amount confirmation (docstring
|
"per CAA 2026 (Pub. L. 119-75), restated 91 FR 44218-44220, "
|
||||||
# pincite, line 65021-65026).
|
"44285-44286 (CMS-1848-P) (task #620)"
|
||||||
citation="90 FR 49928, 49980",
|
),
|
||||||
# NPRM proposals per :pincite:`2KVJ2HKX` — CMS-1832-P, 90 FR
|
# NPRM proposals per :pincite:`2KVJ2HKX` — CMS-1832-P, 90 FR
|
||||||
# 32352, transcribed from data/fr_downloads/2025-13271.txt;
|
# 32352, transcribed from data/fr_downloads/2025-13271.txt;
|
||||||
# disposition from data/fr_downloads/2025-19787.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 "
|
"beginning with the 2026 QP Performance Period (NPRM "
|
||||||
"90 FR 32773). Finalized as proposed (Final Rule 90 FR "
|
"90 FR 32773). Finalized as proposed (Final Rule 90 FR "
|
||||||
"49929: 'finalizing this policy as proposed'). "
|
"49929: 'finalizing this policy as proposed'). "
|
||||||
"QP/Partial QP thresholds: proposed no numeric change "
|
"QP/Partial QP thresholds: CMS-1832-P proposed, and "
|
||||||
"— thresholds remain 75%/50% (QP) and 50%/35% "
|
"the Final Rule finalized, no numeric change from the "
|
||||||
"(Partial QP); see QPP[2026].qp_thresholds and the "
|
"then-current-law payment-year-2028 thresholds "
|
||||||
"module docstring re: the NPRM's internally "
|
"(75%/50% QP, 50%/35% Partial QP; see module "
|
||||||
"inconsistent RIA framing, corrected in the Final "
|
"docstring re: the NPRM's internally inconsistent RIA "
|
||||||
"Rule (NPRM 90 FR 32772 vs 90 FR 32819; Final Rule 90 "
|
"framing, corrected in the Final Rule — NPRM 90 FR "
|
||||||
"FR 49928). "
|
"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 "
|
"Nominal amount standards (42 CFR "
|
||||||
"414.1415(c)(3)(i)(A)/(B)) and the CEHRT requirement "
|
"414.1415(c)(3)(i)(A)/(B)) and the CEHRT requirement "
|
||||||
"(Sec. 414.1415(a)(i)): proposed no change — neither "
|
"(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):
|
# Verified against the regulations.gov docket abstract (2026-08-12):
|
||||||
# CMS-2025-0304 is the CY2026 *PFS* NPRM, not the OPPS rule — skin-substitute
|
# CMS-2025-0304 is the CY2026 *PFS* NPRM, not the OPPS rule — skin-substitute
|
||||||
# payment moved into the PFS rule in CY2026.
|
# 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 = """\
|
_SYSTEM = """\
|
||||||
You classify public comments submitted on CMS's CY2026 PFS proposed rule
|
You classify public comments submitted on CMS's CY2026 PFS proposed rule
|
||||||
(CMS-1832-P, docket CMS-2025-0304), specifically regarding the proposal to
|
(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
|
from __future__ import annotations
|
||||||
|
|
||||||
import duckdb
|
import duckdb
|
||||||
|
import pytest
|
||||||
|
|
||||||
from pfs import nprm
|
from pfs import nprm
|
||||||
|
|
||||||
|
|
||||||
def _fixture(tmp_path):
|
def _fixture(tmp_path, name="CY2026_NPRM_Addendum_B.csv"):
|
||||||
p = tmp_path / "CY2026_NPRM_Addendum_B.csv"
|
p = tmp_path / name
|
||||||
p.write_text(
|
p.write_text(
|
||||||
"HCPCS,MOD,DESCRIPTION,STATUS CODE,WORK RVU,"
|
"HCPCS,MOD,DESCRIPTION,STATUS CODE,WORK RVU,"
|
||||||
"NON-FAC PE RVU,FAC PE RVU,MP RVU\n"
|
"NON-FAC PE RVU,FAC PE RVU,MP RVU\n"
|
||||||
@@ -18,20 +19,135 @@ def _fixture(tmp_path):
|
|||||||
return p
|
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):
|
def test_load_rvu_proposed_loads_and_reloads(tmp_path, monkeypatch):
|
||||||
path = _fixture(tmp_path)
|
path = _fixture(tmp_path)
|
||||||
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path)
|
_patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
|
||||||
con = duckdb.connect()
|
con = duckdb.connect()
|
||||||
out = nprm.load_rvu_proposed(con)
|
out = nprm.load_rvu_proposed(con, year=2026)
|
||||||
assert out["rows"] == 2
|
assert out["rows"] == 2
|
||||||
|
assert out["cms_rule_id"] == "CMS-1832-P"
|
||||||
row = con.execute(
|
row = con.execute(
|
||||||
"SELECT hcpcs, work_rvu, cms_rule_id FROM pfs.rvu_proposed WHERE hcpcs='99213'"
|
"SELECT hcpcs, work_rvu, cms_rule_id FROM pfs.rvu_proposed WHERE hcpcs='99213'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert row == ("99213", 1.3, "CMS-1832-P")
|
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
|
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):
|
def _dirty_fixture(tmp_path):
|
||||||
"""Real-file quirks: padded cells, "NA" markers, a footnote row.
|
"""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):
|
def test_load_rvu_proposed_strips_padding(tmp_path, monkeypatch):
|
||||||
"""Whitespace-padded HCPCS/numeric cells load clean, not null."""
|
"""Whitespace-padded HCPCS/numeric cells load clean, not null."""
|
||||||
path = _dirty_fixture(tmp_path)
|
path = _dirty_fixture(tmp_path)
|
||||||
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path)
|
_patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
|
||||||
con = duckdb.connect()
|
con = duckdb.connect()
|
||||||
nprm.load_rvu_proposed(con)
|
nprm.load_rvu_proposed(con, year=2026)
|
||||||
row = con.execute(
|
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'"
|
"FROM pfs.rvu_proposed WHERE hcpcs='99213'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert row == ("99213", "Office visit est", 1.3, 1.5, 0.55, 0.1)
|
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):
|
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."""
|
""" "NA" in a numeric cell loads as SQL NULL, not a cast error."""
|
||||||
path = _dirty_fixture(tmp_path)
|
path = _dirty_fixture(tmp_path)
|
||||||
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path)
|
_patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
|
||||||
con = duckdb.connect()
|
con = duckdb.connect()
|
||||||
nprm.load_rvu_proposed(con)
|
nprm.load_rvu_proposed(con, year=2026)
|
||||||
row = con.execute(
|
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'"
|
"FROM pfs.rvu_proposed WHERE hcpcs='99214'"
|
||||||
).fetchone()
|
).fetchone()
|
||||||
assert row == (None, None, None, None)
|
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):
|
def test_load_rvu_proposed_drops_footnote_rows(tmp_path, monkeypatch):
|
||||||
"""A footnote row (first cell = long sentence) is not a HCPCS code."""
|
"""A footnote row (first cell = long sentence) is not a HCPCS code."""
|
||||||
path = _dirty_fixture(tmp_path)
|
path = _dirty_fixture(tmp_path)
|
||||||
monkeypatch.setattr(nprm, "_discover_addendum_b", lambda: path)
|
_patch_discovery(monkeypatch, {"sup:2026_PFS_NPRM": path})
|
||||||
con = duckdb.connect()
|
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 out["rows"] == 2 # only 99213 and 99214 — footnote row excluded
|
||||||
assert con.execute("SELECT count(*) FROM pfs.rvu_proposed").fetchone()[0] == 2
|
assert con.execute("SELECT count(*) FROM pfs.rvu_proposed").fetchone()[0] == 2
|
||||||
footnote = con.execute(
|
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
|
- RULES dict: years 2014-2026 (13 entries), all RuleYear instances
|
||||||
- Convenience aliases: cy2024, cy2025, cy2026
|
- Convenience aliases: cy2024, cy2025, cy2026
|
||||||
- Specific parameter values for spot-checked years
|
- Specific parameter values for spot-checked years
|
||||||
|
- PROPOSED registry (final-less years) and proposed_for() helper
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pytest
|
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 ───────────────────────────────────────────────
|
# ── RuleYear model ───────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -296,3 +306,88 @@ class TestProposedRule:
|
|||||||
assert p.anesthesia_cf_qp is None
|
assert p.anesthesia_cf_qp is None
|
||||||
assert p.budget_neutrality_adjustor == 1.0
|
assert p.budget_neutrality_adjustor == 1.0
|
||||||
assert p.telehealth_originating_site_fee is None
|
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:
|
Covers src/qpp/__init__.py:
|
||||||
- QpThresholds / RiskStandards / QppProposed model construction
|
- QpThresholds / RiskStandards / QppProposed model construction
|
||||||
- QppYear model construction with required and default fields
|
- 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
|
- qp_cf_applies: False before performance year 2024, True from 2024 onward
|
||||||
- Every entry has a non-empty citation
|
- 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
|
- 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
|
from __future__ import annotations
|
||||||
@@ -110,10 +111,10 @@ class TestQppYear:
|
|||||||
|
|
||||||
|
|
||||||
class TestQppDict:
|
class TestQppDict:
|
||||||
EXPECTED_YEARS = [2023, 2024, 2025, 2026]
|
EXPECTED_YEARS = [2023, 2024, 2025, 2026, 2027]
|
||||||
|
|
||||||
def test_count(self):
|
def test_count(self):
|
||||||
assert len(QPP) == 4
|
assert len(QPP) == 5
|
||||||
|
|
||||||
def test_keys(self):
|
def test_keys(self):
|
||||||
assert sorted(QPP.keys()) == self.EXPECTED_YEARS
|
assert sorted(QPP.keys()) == self.EXPECTED_YEARS
|
||||||
@@ -150,9 +151,17 @@ class TestQpCfApplies:
|
|||||||
def test_2026_true(self):
|
def test_2026_true(self):
|
||||||
assert QPP[2026].qp_cf_applies is True
|
assert QPP[2026].qp_cf_applies is True
|
||||||
|
|
||||||
|
def test_2027_true(self):
|
||||||
|
assert QPP[2027].qp_cf_applies is True
|
||||||
|
|
||||||
|
|
||||||
class TestApmIncentivePct:
|
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):
|
def test_2023_incentive(self):
|
||||||
assert QPP[2023].apm_incentive_pct == 3.5
|
assert QPP[2023].apm_incentive_pct == 3.5
|
||||||
@@ -163,8 +172,22 @@ class TestApmIncentivePct:
|
|||||||
def test_2025_no_incentive(self):
|
def test_2025_no_incentive(self):
|
||||||
assert QPP[2025].apm_incentive_pct is None
|
assert QPP[2025].apm_incentive_pct is None
|
||||||
|
|
||||||
def test_2026_no_incentive(self):
|
def test_2026_incentive_revived_by_caa_2026(self):
|
||||||
assert QPP[2026].apm_incentive_pct is None
|
# 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 ──────────────────────────────
|
# ── QPP[2026].proposed — CMS-1832-P ──────────────────────────────
|
||||||
@@ -186,25 +209,75 @@ class TestProposed:
|
|||||||
assert QPP[2025].proposed is None
|
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 ───────────────────────────
|
# ── Exact transcribed threshold values ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestSpecificValues:
|
class TestSpecificValues:
|
||||||
"""Verify transcribed QP/Partial QP thresholds and risk standards.
|
"""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])
|
@pytest.mark.parametrize("year", [2025, 2027])
|
||||||
def test_qp_thresholds(self, year):
|
def test_qp_thresholds_75_50(self, year):
|
||||||
assert QPP[year].qp_thresholds.payment_amount_pct == 75.0
|
assert QPP[year].qp_thresholds.payment_amount_pct == 75.0
|
||||||
assert QPP[year].qp_thresholds.patient_count_pct == 50.0
|
assert QPP[year].qp_thresholds.patient_count_pct == 50.0
|
||||||
|
|
||||||
@pytest.mark.parametrize("year", [2023, 2024, 2025, 2026])
|
@pytest.mark.parametrize("year", [2023, 2024, 2026])
|
||||||
def test_partial_qp_thresholds(self, year):
|
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.payment_amount_pct == 50.0
|
||||||
assert QPP[year].partial_qp_thresholds.patient_count_pct == 35.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):
|
def test_risk_standards(self, year):
|
||||||
# Revenue-based standard (42 CFR 414.1415(c)(3)(i)(A)) is
|
# Revenue-based standard (42 CFR 414.1415(c)(3)(i)(A)) is
|
||||||
# transcribed (8%); the benchmark-based standard (42 CFR
|
# 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.revenue_nominal_pct == 8.0
|
||||||
assert QPP[year].risk_standards.benchmark_nominal_pct is None
|
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):
|
def test_cehrt_required(self, year):
|
||||||
assert QPP[year].cehrt_required is True
|
assert QPP[year].cehrt_required is True
|
||||||
|
|
||||||
@@ -235,6 +308,9 @@ class TestForPaymentYear:
|
|||||||
def test_2028_maps_to_performance_2026(self):
|
def test_2028_maps_to_performance_2026(self):
|
||||||
assert for_payment_year(2028) is QPP[2026]
|
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):
|
def test_unknown_payment_year_raises_keyerror(self):
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
for_payment_year(1999)
|
for_payment_year(1999)
|
||||||
|
|||||||
@@ -158,6 +158,12 @@ def test_docket_rules_maps_cy2026_pfs():
|
|||||||
assert DOCKET_RULES["CMS-2025-0304"] == "CMS-1832-P"
|
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():
|
def test_system_prompt_names_the_pfs_rule():
|
||||||
from rex.comments.classify import _SYSTEM
|
from rex.comments.classify import _SYSTEM
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user