feat(pfs): PROPOSED registry + proposed_for — CY2027 NPRM parameters (refs #611)
This commit is contained in:
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user