diff --git a/changelog.d/716.fixed b/changelog.d/716.fixed new file mode 100644 index 000000000..459c35a8d --- /dev/null +++ b/changelog.d/716.fixed @@ -0,0 +1 @@ +Adds a CMS-sourced total Medicare enrollment count target matching medicare_enrolled semantics. diff --git a/policyengine_us_data/calibration/target_config.yaml b/policyengine_us_data/calibration/target_config.yaml index e7adab5d1..eabf951f3 100644 --- a/policyengine_us_data/calibration/target_config.yaml +++ b/policyengine_us_data/calibration/target_config.yaml @@ -122,6 +122,9 @@ include: geo_level: national - variable: medicaid geo_level: national + - variable: person_count + geo_level: national + domain_variable: medicare_enrolled - variable: medicare_part_b_premium geo_level: national - variable: other_medical_expenses diff --git a/policyengine_us_data/db/etl_national_targets.py b/policyengine_us_data/db/etl_national_targets.py index 9c882cf1b..d55c34ed5 100644 --- a/policyengine_us_data/db/etl_national_targets.py +++ b/policyengine_us_data/db/etl_national_targets.py @@ -16,6 +16,9 @@ get_beneficiary_paid_medicare_part_b_premiums_notes, get_beneficiary_paid_medicare_part_b_premiums_source, get_beneficiary_paid_medicare_part_b_premiums_target, + get_medicare_enrollment_notes, + get_medicare_enrollment_source, + get_medicare_enrollment_target, ) from policyengine_us_data.utils.db import ( DEFAULT_YEAR, @@ -316,6 +319,13 @@ def extract_national_targets(year: int = DEFAULT_YEAR): "notes": "ACA Premium Tax Credit recipients", "year": 2024, }, + { + "constraint_variable": "medicare_enrolled", + "person_count": get_medicare_enrollment_target(2024), + "source": get_medicare_enrollment_source(2024), + "notes": get_medicare_enrollment_notes(2024), + "year": 2024, + }, { "constraint_variable": "spm_unit_energy_subsidy_reported", "target_variable": "household_count", diff --git a/policyengine_us_data/utils/cms_medicare.py b/policyengine_us_data/utils/cms_medicare.py index b710cbdea..755f4e295 100644 --- a/policyengine_us_data/utils/cms_medicare.py +++ b/policyengine_us_data/utils/cms_medicare.py @@ -2,6 +2,10 @@ 2024: 139.837e9, } +MEDICARE_ENROLLMENT_TARGETS = { + 2024: 68_030_000, +} + MEDICARE_STATE_BUY_IN_MINIMUM_BENEFICIARIES = { 2024: 10_000_000, @@ -13,6 +17,31 @@ } +def get_medicare_enrollment_target(year: int) -> float: + try: + return MEDICARE_ENROLLMENT_TARGETS[year] + except KeyError as exc: + raise ValueError(f"No Medicare enrollment target sourced for {year}.") from exc + + +def get_medicare_enrollment_source(year: int) -> str: + enrollment = MEDICARE_ENROLLMENT_TARGETS[year] + return ( + "CMS 2024 Medicare Trustees Report Table V.B3 intermediate estimate " + f"for {year} total Medicare enrollment " + f"({enrollment:,.0f} beneficiaries with HI and/or SMI coverage)" + ) + + +def get_medicare_enrollment_notes(year: int) -> str: + return ( + "Total Medicare enrollment count for beneficiaries with HI and/or " + "SMI coverage. This matches policyengine-us medicare_enrolled " + "semantics and remains separate from beneficiary-paid Part B " + "premiums." + ) + + def get_beneficiary_paid_medicare_part_b_premiums_target(year: int) -> float: try: return BENEFICIARY_PAID_MEDICARE_PART_B_PREMIUM_TARGETS[year] diff --git a/policyengine_us_data/utils/loss.py b/policyengine_us_data/utils/loss.py index 7150496ac..45ff47e8b 100644 --- a/policyengine_us_data/utils/loss.py +++ b/policyengine_us_data/utils/loss.py @@ -14,6 +14,7 @@ ) from policyengine_us_data.utils.cms_medicare import ( get_beneficiary_paid_medicare_part_b_premiums_target, + get_medicare_enrollment_target, ) from policyengine_us_data.db.etl_irs_soi import get_national_geography_soi_target from policyengine_core.reforms import Reform @@ -113,6 +114,17 @@ ], } + +def _add_medicare_enrollment_target(loss_matrix, targets_array, sim, time_period): + label = "nation/cms/medicare_enrollment" + enrolled = sim.calculate( + "medicare_enrolled", map_to="person", period=time_period + ).values + loss_matrix[label] = sim.map_result(enrolled.astype(float), "person", "household") + targets_array.append(get_medicare_enrollment_target(time_period)) + return targets_array, loss_matrix + + ACA_SPENDING_TARGETS = { 2024: 98e9, } @@ -726,6 +738,13 @@ def build_loss_matrix(dataset: type, time_period): targets_array.append(aca_enrollment_target) + targets_array, loss_matrix = _add_medicare_enrollment_target( + loss_matrix, + targets_array, + sim, + time_period, + ) + # EITC targets. # # Authoritative source: IRS SOI TY2022 tables. Treasury's diff --git a/tests/unit/calibration/test_loss_targets.py b/tests/unit/calibration/test_loss_targets.py index a6d030ca8..59491c8a8 100644 --- a/tests/unit/calibration/test_loss_targets.py +++ b/tests/unit/calibration/test_loss_targets.py @@ -3,6 +3,7 @@ import pytest from policyengine_us_data.utils.loss import ( + _add_medicare_enrollment_target, _get_aca_national_targets, _add_ctc_targets, _get_medicaid_national_targets, @@ -90,6 +91,26 @@ def map_result(self, values, source_entity, target_entity, how=None): return np.asarray(values, dtype=np.float32) +class _FakeMedicareEnrollmentSimulation: + def __init__(self): + self.calculate_calls = [] + self.map_result_calls = [] + + def calculate(self, variable, map_to=None, period=None): + self.calculate_calls.append((variable, map_to, period)) + if variable != "medicare_enrolled": + raise AssertionError(f"Unexpected variable {variable!r}") + if map_to != "person": + raise AssertionError(f"Unexpected map_to {map_to!r}") + return _FakeArrayResult([1.0, 0.0, 1.0]) + + def map_result(self, values, source_entity, target_entity, how=None): + self.map_result_calls.append((source_entity, target_entity, how)) + assert source_entity == "person" + assert target_entity == "household" + return np.asarray(values, dtype=np.float32) + + def test_add_ctc_targets(monkeypatch): monkeypatch.setattr( "policyengine_us_data.utils.loss.get_national_geography_soi_target", @@ -128,3 +149,25 @@ def test_add_ctc_targets(monkeypatch): def test_tanf_hardcoded_target_uses_fy2024_basic_assistance_total(): assert HARD_CODED_TOTALS["tanf"] == pytest.approx(7_788_317_474.55) + + +def test_add_medicare_enrollment_target(monkeypatch): + monkeypatch.setattr( + "policyengine_us_data.utils.loss.get_medicare_enrollment_target", + lambda year: 68_030_000.0, + ) + sim = _FakeMedicareEnrollmentSimulation() + + targets, loss_matrix = _add_medicare_enrollment_target( + pd.DataFrame(), + [], + sim, + 2024, + ) + + assert targets == [68_030_000.0] + assert sim.calculate_calls == [("medicare_enrolled", "person", 2024)] + np.testing.assert_array_equal( + loss_matrix["nation/cms/medicare_enrollment"], + np.array([1.0, 0.0, 1.0], dtype=np.float32), + ) diff --git a/tests/unit/test_cms_medicare_targets.py b/tests/unit/test_cms_medicare_targets.py index d780bf8d4..b1a826643 100644 --- a/tests/unit/test_cms_medicare_targets.py +++ b/tests/unit/test_cms_medicare_targets.py @@ -4,6 +4,9 @@ get_beneficiary_paid_medicare_part_b_premiums_notes, get_beneficiary_paid_medicare_part_b_premiums_source, get_beneficiary_paid_medicare_part_b_premiums_target, + get_medicare_enrollment_notes, + get_medicare_enrollment_source, + get_medicare_enrollment_target, ) @@ -23,3 +26,20 @@ def test_beneficiary_paid_medicare_part_b_notes_describe_out_of_pocket_semantics notes = get_beneficiary_paid_medicare_part_b_premiums_notes(2024) assert "out-of-pocket" in notes assert "gross trust-fund premium income" in notes + + +def test_medicare_enrollment_target_2024_is_sourced(): + assert get_medicare_enrollment_target(2024) == pytest.approx(68_030_000) + + +def test_medicare_enrollment_source_mentions_trustees_table(): + source = get_medicare_enrollment_source(2024) + assert "2024 Medicare Trustees Report" in source + assert "Table V.B3" in source + assert "total Medicare enrollment" in source + + +def test_medicare_enrollment_notes_match_model_semantics(): + notes = get_medicare_enrollment_notes(2024) + assert "HI and/or SMI" in notes + assert "medicare_enrolled" in notes diff --git a/tests/unit/test_etl_national_targets.py b/tests/unit/test_etl_national_targets.py index 84d8c748b..798202a65 100644 --- a/tests/unit/test_etl_national_targets.py +++ b/tests/unit/test_etl_national_targets.py @@ -199,3 +199,66 @@ def test_load_national_targets_supports_liheap_household_counts(tmp_path, monkey ).first() assert liheap_target is not None assert liheap_target.value == 5_876_646 + + +def test_load_national_targets_supports_medicare_enrollment_counts( + tmp_path, monkeypatch +): + calibration_dir = tmp_path / "calibration" + calibration_dir.mkdir() + db_uri = f"sqlite:///{calibration_dir / 'policy_data.db'}" + engine = create_database(db_uri) + + with Session(engine) as session: + national = _make_stratum(session, notes="United States") + assert national is not None + + monkeypatch.setattr( + "policyengine_us_data.db.etl_national_targets.STORAGE_FOLDER", + tmp_path, + ) + + conditional_targets = [ + { + "constraint_variable": "medicare_enrolled", + "person_count": 68_030_000, + "source": "CMS 2024 Medicare Trustees Report Table V.B3", + "notes": "Total Medicare enrollment count", + "year": 2024, + } + ] + + load_national_targets( + direct_targets_df=pd.DataFrame(), + tax_filer_df=pd.DataFrame(), + tax_expenditure_df=pd.DataFrame(), + conditional_targets=conditional_targets, + ) + + with Session(engine) as session: + medicare_stratum = session.exec( + select(Stratum).where( + Stratum.notes == "National medicare_enrolled Recipients" + ) + ).first() + assert medicare_stratum is not None + + constraints = { + ( + constraint.constraint_variable, + constraint.operation, + constraint.value, + ) + for constraint in medicare_stratum.constraints_rel + } + assert ("medicare_enrolled", ">", "0") in constraints + + medicare_target = session.exec( + select(Target).where( + Target.stratum_id == medicare_stratum.stratum_id, + Target.variable == "person_count", + Target.period == 2024, + ) + ).first() + assert medicare_target is not None + assert medicare_target.value == 68_030_000