feat(pfs): CY2027 final rule parameters; fix CY2025/2026 telehealth fees
Some checks failed
CI / lint (push) Successful in 1m12s
CI / notebooks-smoke (push) Successful in 1m56s
Deploy / changes (push) Successful in 18s
CI / test (push) Successful in 4m48s
Infra CI / notebooks (push) Successful in 1m37s
Infra CI / zotero (push) Successful in 21s
Infra CI / docs (push) Failing after 1m4s
Deploy / notebooks (push) Has been cancelled
Deploy / report (push) Has been cancelled
Deploy / zotero (push) Has been cancelled
Deploy / docs (push) Has been cancelled
Deploy / api (push) Has been cancelled
Deploy / bcda (push) Has been cancelled
Deploy / llm (push) Has been cancelled
Deploy / mc (push) Has been cancelled
Infra CI / mc (push) Has been cancelled
Infra CI / bcda (push) Has been cancelled
Infra CI / llm (push) Has been cancelled
Infra CI / api (push) Has been cancelled

RULES[2027] from the CY2027 PFS final rule (CMS-1848-F, 91 FR 70992,
:pincite:JCWARTJD): non-QP CF 32.6286, QP CF 32.9548, non-QP anesthesia
20.1056 (QP 20.3068 noted), -0.12% budget neutrality (91 FR 71653), and
the Q3014 fee $32.68 read from Table A-C1, an image at 91 FR 71027.
The NPRM block moves from PROPOSED[2027] to RULES[2027].proposed, so
proposed_for(2027) is unchanged for callers; PROPOSED is empty until
CY2028's NPRM.

Table A-C1 is CMS's full Q3014 history, and it shows two registry
errors: CY2025 held 29.96 (CY2024's fee; correct $31.01) and CY2026
held 30.38 (no such year; correct $31.85).
This commit is contained in:
kert
2026-11-11 13:23:47 -05:00
parent ccae75c805
commit 24664656c3
2 changed files with 178 additions and 101 deletions

View File

@@ -25,7 +25,9 @@ Mid-year CF splits documented by :pincite:`VVBEVYLC`::
All other CY2002 – CY2026 years have a single CF for the full year.
CY2026 is the first year with four simultaneous CFs (APM / non-APM
× standard / anesthesia) under MACRA 2015.
× standard / anesthesia) under MACRA 2015. CY2027 (CMS-1848-F) keeps the
split; its CFs come from the final rule itself (:pincite:`JCWARTJD`),
not the AMA table, which had not published CY2027 at transcription.
Usage::
@@ -49,17 +51,17 @@ one applies:
(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).
is no ``RULES[year]`` to hang it on (CY2027 lived here from its
July 2026 NPRM until the final rule of Nov 9, 2026; empty since).
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.
migration path: when a year's Final Rule ships, ``RULES[year]`` gets
created with its own ``proposed=...`` block and the ``PROPOSED[year]``
entry is retired — ``proposed_for(year)`` keeps returning the right
thing across that move without callers changing (CY2027 made the move
on 2026-11-11). ``RuleYear`` itself needs no change for this to work.
"""
from __future__ import annotations
@@ -420,7 +422,10 @@ RULES: dict[int, RuleYear] = {
2025, 32.3465, "expiration of CY2024 temporary 2.93% update"
),
clinical_labor_rate_update=True,
telehealth_originating_site_fee=29.96,
# Was 29.96 — that is CY2024's fee. CMS's Q3014 history,
# Table A-C1 of the CY2027 final rule (91 FR 71027), gives
# CY2025 = $31.01 (MEI 3.5%).
telehealth_originating_site_fee=31.01,
federal_register_citation="89 FR 98452",
),
2026: RuleYear(
@@ -442,7 +447,10 @@ RULES: dict[int, RuleYear] = {
anesthesia_cf=20.4976, # non-APM anesthesia
budget_neutrality_adjustor=1.0049, # +0.49% BN adjustment
clinical_labor_rate_update=True,
telehealth_originating_site_fee=30.38,
# Was 30.38, which matches no year in CMS's Q3014 history
# (Table A-C1, 91 FR 71027): CY2026 = $31.85 (MEI 2.7%),
# the same amount the NPRM proposed.
telehealth_originating_site_fee=31.85,
# Corrected from "90 FR 101174" (mistranscribed; no such page in
# this rule). The Final Rule (RIN 0938-AV50, Vol. 90 No. 212,
# Nov 5, 2025) runs 90 FR 49266-50481; per this registry's
@@ -474,6 +482,93 @@ RULES: dict[int, RuleYear] = {
),
),
),
2027: RuleYear(
# CY2027 PFS Final Rule (CMS-1848-F), FR Doc 2026-22743,
# 91 FR 70992-72020, published 2026-11-09, effective
# 2027-01-01, per :pincite:`JCWARTJD`. CFs and the BN adjustor
# are the RIA's CF derivation at 91 FR 71653
# (data/fr_downloads/2026-22743.txt lines ~67750-67767): the
# CY2026 CFs *without* the one-year 2.50% statutory increase,
# times a -0.12% budget-neutrality adjustment, times the
# section 1848(d)(20) updates (QP +0.75%, non-QP +0.25%).
year=2027,
conversion_factor=32.6286, # nonqualifying APM standard
cf_qp=32.9548, # qualifying APM standard
cf_segments=_full_year(
2027,
32.6286,
"non-APM standard CF; CY2026's one-year 2.50% increase removed",
),
# Nonqualifying APM anesthesia CF; the QP anesthesia CF is
# 20.3068 (same sentence, 91 FR 71653) — RuleYear has no field
# for it yet.
anesthesia_cf=20.1056,
budget_neutrality_adjustor=0.9988, # "-0.12 percent"
# HCPCS Q3014, Table A-C1 (an image in both the PDF and the
# text edition), 91 FR 71027: $32.68, MEI 2.6%.
telehealth_originating_site_fee=32.68,
# clinical_labor_rate_update / supply_equipment_price_update
# stay False: the rule continues only a phased pricing
# transition for three supply packs (Table A-B4), no general
# clinical-labor or supply/equipment refresh.
federal_register_citation="91 FR 70992",
# NPRM values, moved here from PROPOSED[2027] when the Final
# Rule shipped (see module docstring).
proposed=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)."
),
),
),
}
@@ -481,70 +576,15 @@ RULES: dict[int, RuleYear] = {
#
# `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` holds `ProposedRule` values for years whose NPRM is out
# but whose Final Rule is not. When the Final Rule ships, `RULES[year]`
# is created with `proposed=` that block and the `PROPOSED[year]` entry
# is retired — callers that used `proposed_for(year)` see no change
# since the helper checks `RULES` first. Empty since CY2027's final
# rule (CMS-1848-F) moved its NPRM under RULES[2027]; CY2028's NPRM
# (expected July 2027) goes here 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)."
),
),
}
PROPOSED: dict[int, ProposedRule] = {}
def proposed_for(year: int) -> ProposedRule | None:
@@ -552,8 +592,8 @@ def proposed_for(year: int) -> ProposedRule | None:
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
for years whose NPRM has no Final Rule yet (see ``PROPOSED``
above). Returns ``None`` if neither
source has proposed parameters for ``year``.
This indirection is what lets a year migrate from ``PROPOSED`` to

View File

@@ -104,21 +104,21 @@ class TestRuleYear:
class TestRulesDict:
"""RULES maps calendar year -> RuleYear for 2014-2026."""
"""RULES maps calendar year -> RuleYear for 2014-2027."""
EXPECTED_YEARS = list(range(2014, 2027))
EXPECTED_YEARS = list(range(2014, 2028))
def test_count(self):
assert len(RULES) == 13
assert len(RULES) == 14
def test_keys(self):
assert sorted(RULES.keys()) == self.EXPECTED_YEARS
@pytest.mark.parametrize("year", list(range(2014, 2027)))
@pytest.mark.parametrize("year", list(range(2014, 2028)))
def test_values_are_ruleyear(self, year):
assert isinstance(RULES[year], RuleYear)
@pytest.mark.parametrize("year", list(range(2014, 2027)))
@pytest.mark.parametrize("year", list(range(2014, 2028)))
def test_year_field_matches_key(self, year):
assert RULES[year].year == year
@@ -156,7 +156,8 @@ class TestSpecificValues:
assert cy2026.cf_qp == 33.5675
def test_cy2026_telehealth_fee(self):
assert cy2026.telehealth_originating_site_fee == 30.38
# Table A-C1 (Q3014 history), 91 FR 71027 — was mis-recorded as 30.38.
assert cy2026.telehealth_originating_site_fee == 31.85
def test_cy2026_federal_register(self):
assert cy2026.federal_register_citation == "90 FR 49266"
@@ -168,7 +169,8 @@ class TestSpecificValues:
assert cy2025.conversion_factor == 32.3465
def test_cy2025_telehealth_fee(self):
assert cy2025.telehealth_originating_site_fee == 29.96
# Table A-C1, 91 FR 71027 — 29.96 was CY2024's fee.
assert cy2025.telehealth_originating_site_fee == 31.01
def test_cy2024_conversion_factor(self):
assert cy2024.conversion_factor == 33.2875
@@ -319,11 +321,14 @@ class TestProposedForHelper:
# 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_resolves_from_proposed_registry_when_year_absent_from_rules(
self, monkeypatch
):
# A final-less year (CY2028's NPRM, next July) lives in PROPOSED.
prop = RULES[2027].proposed.model_copy(update={"cms_rule_id": "CMS-9999-P"})
monkeypatch.setitem(PROPOSED, 2028, prop)
assert 2028 not in RULES
assert proposed_for(2028) is prop
def test_missing_year_returns_none(self):
assert proposed_for(1999) is None
@@ -335,15 +340,16 @@ class TestProposedForHelper:
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
def test_2027_moved_from_proposed_to_rules(self):
# The CY2027 final rule shipped: its NPRM block now hangs off
# RULES[2027] and the PROPOSED entry is retired.
assert 2027 not in PROPOSED
assert proposed_for(2027) is RULES[2027].proposed
assert proposed_for(2027).cms_rule_id == "CMS-1848-P"
class TestCy2027Proposed:
"""PROPOSED[2027] — CY2027 NPRM (CMS-1848-P) transcribed values.
"""proposed_for(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`.
@@ -352,7 +358,7 @@ class TestCy2027Proposed:
def test_identity_fields(self):
from datetime import date
p = PROPOSED[2027]
p = proposed_for(2027)
assert p.cms_rule_id == "CMS-1848-P"
assert p.fr_document_number == "2026-14327"
assert p.federal_register_citation == "91 FR 43842"
@@ -363,31 +369,62 @@ class TestCy2027Proposed:
# 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
assert proposed_for(2027).comment_close == date(2026, 9, 14)
assert proposed_for(2027).comment_close > proposed_for(2027).published
def test_standard_conversion_factors(self):
p = PROPOSED[2027]
p = proposed_for(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]
p = proposed_for(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
assert proposed_for(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
assert proposed_for(2027).telehealth_originating_site_fee == 32.65
def test_not_equal_to_cy2026_final_or_proposed(self):
p2027 = PROPOSED[2027]
p2027 = proposed_for(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
class TestCy2027Final:
"""RULES[2027] — CY2027 PFS Final Rule (CMS-1848-F), 91 FR 70992,
published 2026-11-09, per :pincite:`JCWARTJD`. CF sentence at
91 FR 71653; Q3014 fee in Table A-C1, 91 FR 71027."""
def test_conversion_factors(self):
r = RULES[2027]
assert r.conversion_factor == 32.6286 # nonqualifying APM
assert r.cf_qp == 32.9548 # qualifying APM
assert r.anesthesia_cf == 20.1056 # nonqualifying APM anesthesia
def test_budget_neutrality_and_fee(self):
r = RULES[2027]
assert r.budget_neutrality_adjustor == 0.9988 # -0.12 percent
assert r.telehealth_originating_site_fee == 32.68
def test_citation_and_single_segment(self):
from datetime import date
r = RULES[2027]
assert r.federal_register_citation == "91 FR 70992"
assert r.cf_for_date(date(2027, 7, 1)) == 32.6286
def test_final_differs_from_proposed(self):
r = RULES[2027]
# NPRM 32.8409 / 33.1693 → final 32.6286 / 32.9548: BN flipped
# from +0.53% proposed to -0.12% final.
assert r.conversion_factor < r.proposed.conversion_factor
assert r.cf_qp < r.proposed.cf_qp