diff --git a/changelog.d/medicaid-slcsp-cost.changed.md b/changelog.d/medicaid-slcsp-cost.changed.md new file mode 100644 index 00000000000..f013a7e798d --- /dev/null +++ b/changelog.d/medicaid-slcsp-cost.changed.md @@ -0,0 +1 @@ +Moved Medicaid conditional cost allocation into PolicyEngine US using an SLCSP age, family-composition, and location cost index. diff --git a/policyengine_us/tests/core/test_medicaid_slcsp_cost.py b/policyengine_us/tests/core/test_medicaid_slcsp_cost.py new file mode 100644 index 00000000000..20840169d0f --- /dev/null +++ b/policyengine_us/tests/core/test_medicaid_slcsp_cost.py @@ -0,0 +1,401 @@ +import numpy as np +import pytest +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation +from policyengine_us.variables.gov.hhs.medicaid.costs.state_aggregate_helpers import ( + sum_by_state, +) + + +class NoOpReform(Reform): + def apply(self): + pass + + +def test_sum_by_state_does_not_mix_states(): + # Cross-state contamination check: each person should receive only their + # own state's total, never another state's. + values = np.array([1.0, 2.0, 3.0, 4.0]) + state = np.array(["TX", "CO", "TX", "CO"]) + + result = sum_by_state(values, state) + + # TX = 1 + 3 = 4; CO = 2 + 4 = 6. + np.testing.assert_allclose(result, [4.0, 6.0, 4.0, 6.0]) + + +def test_medicaid_slcsp_cost_index_allocates_vt_family_tier(): + simulation = Simulation( + situation={ + "people": { + "adult_1": {"age": {"2026": 40}}, + "adult_2": {"age": {"2026": 38}}, + "child_1": { + "age": {"2026": 10}, + "is_tax_unit_dependent": {"2026": True}, + }, + "child_2": { + "age": {"2026": 8}, + "is_tax_unit_dependent": {"2026": True}, + }, + }, + "tax_units": { + "tax_unit": {"members": ["adult_1", "adult_2", "child_1", "child_2"]} + }, + "spm_units": { + "spm_unit": {"members": ["adult_1", "adult_2", "child_1", "child_2"]} + }, + "families": { + "family": {"members": ["adult_1", "adult_2", "child_1", "child_2"]} + }, + "households": { + "household": { + "members": ["adult_1", "adult_2", "child_1", "child_2"], + "state_code": {"2026": "VT"}, + } + }, + } + ) + + base_cost = simulation.calculate("slcsp_age_0", "2026-01")[0] + index = simulation.calculate("medicaid_slcsp_cost_index", 2026) + + # VT two-adults-with-children multiplier is 2.81, spread over 4 members. + np.testing.assert_allclose(index, np.full(4, base_cost * 2.81 / 4)) + + +def test_medicaid_slcsp_cost_index_allocates_ny_child_only(): + simulation = Simulation( + situation={ + "people": { + "child_1": { + "age": {"2026": 12}, + "is_tax_unit_dependent": {"2026": True}, + }, + "child_2": { + "age": {"2026": 10}, + "is_tax_unit_dependent": {"2026": True}, + }, + }, + "tax_units": {"tax_unit": {"members": ["child_1", "child_2"]}}, + "spm_units": {"spm_unit": {"members": ["child_1", "child_2"]}}, + "families": {"family": {"members": ["child_1", "child_2"]}}, + "households": { + "household": { + "members": ["child_1", "child_2"], + "state_code": {"2026": "NY"}, + } + }, + } + ) + + base_cost = simulation.calculate("slcsp_age_0", "2026-01")[0] + index = simulation.calculate("medicaid_slcsp_cost_index", 2026) + + # NY child-only multiplier is 0.412, spread over the 2 children. + np.testing.assert_allclose(index, np.full(2, base_cost * 0.412 / 2)) + + +def test_medicaid_slcsp_cost_index_allocates_ny_family_tier(): + simulation = Simulation( + situation={ + "people": { + "adult_1": {"age": {"2026": 40}}, + "adult_2": {"age": {"2026": 38}}, + "child_1": { + "age": {"2026": 10}, + "is_tax_unit_dependent": {"2026": True}, + }, + "child_2": { + "age": {"2026": 8}, + "is_tax_unit_dependent": {"2026": True}, + }, + }, + "tax_units": { + "tax_unit": {"members": ["adult_1", "adult_2", "child_1", "child_2"]} + }, + "spm_units": { + "spm_unit": {"members": ["adult_1", "adult_2", "child_1", "child_2"]} + }, + "families": { + "family": {"members": ["adult_1", "adult_2", "child_1", "child_2"]} + }, + "households": { + "household": { + "members": ["adult_1", "adult_2", "child_1", "child_2"], + "state_code": {"2026": "NY"}, + } + }, + } + ) + + base_cost = simulation.calculate("slcsp_age_0", "2026-01")[0] + index = simulation.calculate("medicaid_slcsp_cost_index", 2026) + + np.testing.assert_allclose(index, np.full(4, base_cost * 2.85 / 4)) + + +def test_medicaid_slcsp_cost_index_preserves_vt_child_only_fallback(): + simulation = Simulation( + situation={ + "people": { + "child_1": { + "age": {"2026": 12}, + "is_tax_unit_dependent": {"2026": True}, + }, + "child_2": { + "age": {"2026": 10}, + "is_tax_unit_dependent": {"2026": True}, + }, + }, + "tax_units": {"tax_unit": {"members": ["child_1", "child_2"]}}, + "spm_units": {"spm_unit": {"members": ["child_1", "child_2"]}}, + "families": {"family": {"members": ["child_1", "child_2"]}}, + "households": { + "household": { + "members": ["child_1", "child_2"], + "state_code": {"2026": "VT"}, + } + }, + } + ) + + base_cost = simulation.calculate("slcsp_age_0", "2026-01")[0] + index = simulation.calculate("medicaid_slcsp_cost_index", 2026) + + np.testing.assert_allclose(index, np.full(2, base_cost)) + + +def test_medicaid_cost_if_enrolled_is_not_pathway_dependent(): + simulation = Simulation( + situation={ + "people": { + "ssi_adult": { + "age": {"2026": 40}, + "is_medicaid_eligible": {"2026": True}, + "is_ssi_recipient_for_medicaid": {"2026": True}, + }, + "magi_adult": { + "age": {"2026": 40}, + "is_medicaid_eligible": {"2026": True}, + }, + }, + "tax_units": { + "ssi_unit": {"members": ["ssi_adult"]}, + "magi_unit": {"members": ["magi_adult"]}, + }, + "spm_units": { + "ssi_spm": {"members": ["ssi_adult"]}, + "magi_spm": {"members": ["magi_adult"]}, + }, + "families": { + "ssi_family": {"members": ["ssi_adult"]}, + "magi_family": {"members": ["magi_adult"]}, + }, + "households": { + "ssi_household": { + "members": ["ssi_adult"], + "state_code": {"2026": "CA"}, + }, + "magi_household": { + "members": ["magi_adult"], + "state_code": {"2026": "CA"}, + }, + }, + } + ) + + costs = simulation.calculate("medicaid_cost_if_enrolled", 2026) + + assert costs[0] == pytest.approx(costs[1]) + assert costs[0] > 0 + + +def test_household_medicaid_cost_uses_state_enrollment_denominator(): + simulation = Simulation( + situation={ + "people": { + "person_1": {"medicaid_slcsp_cost_index": {"2026": 100}}, + "person_2": {"medicaid_slcsp_cost_index": {"2026": 200}}, + }, + "tax_units": { + "tax_unit_1": {"members": ["person_1"]}, + "tax_unit_2": {"members": ["person_2"]}, + }, + "spm_units": { + "spm_unit_1": {"members": ["person_1"]}, + "spm_unit_2": {"members": ["person_2"]}, + }, + "families": { + "family_1": {"members": ["person_1"]}, + "family_2": {"members": ["person_2"]}, + }, + "households": { + "household_1": { + "members": ["person_1"], + "state_code": {"2026": "CA"}, + }, + "household_2": { + "members": ["person_2"], + "state_code": {"2026": "CA"}, + }, + }, + } + ) + + costs = simulation.calculate("medicaid_cost_if_enrolled", 2026) + + assert costs[1] == pytest.approx(costs[0] * 2) + assert costs[0] > 0 + assert costs[1] < 50_000 + + +def test_reform_medicaid_denominator_uses_baseline_enrollment(): + simulation = Simulation( + situation={ + "people": { + "person_1": { + "is_medicaid_eligible": {"2026": True}, + "medicaid_slcsp_cost_index": {"2026": 100}, + }, + "person_2": { + "is_medicaid_eligible": {"2026": True}, + "medicaid_slcsp_cost_index": {"2026": 200}, + }, + }, + "tax_units": { + "tax_unit_1": {"members": ["person_1"]}, + "tax_unit_2": {"members": ["person_2"]}, + }, + "spm_units": { + "spm_unit_1": {"members": ["person_1"]}, + "spm_unit_2": {"members": ["person_2"]}, + }, + "families": { + "family_1": {"members": ["person_1"]}, + "family_2": {"members": ["person_2"]}, + }, + "households": { + "household_1": { + "members": ["person_1"], + "state_code": {"2026": "CA"}, + }, + "household_2": { + "members": ["person_2"], + "state_code": {"2026": "CA"}, + }, + }, + }, + reform=NoOpReform, + ) + simulation.is_over_dataset = True + simulation.baseline.is_over_dataset = True + + simulation.set_input("medicaid_enrolled", 2026, [True, False]) + + np.testing.assert_allclose( + simulation.calculate("medicaid_slcsp_state_denominator", 2026), + [300, 300], + ) + + +def test_medicaid_cost_allocation_recovers_state_spending(): + # The point of the allocation: over a dataset, the weighted sum of + # per-enrollee costs in a state must recover that state's total spending. + simulation = Simulation( + situation={ + "people": { + "person_1": {"medicaid_slcsp_cost_index": {"2026": 100}}, + "person_2": {"medicaid_slcsp_cost_index": {"2026": 200}}, + "person_3": {"medicaid_slcsp_cost_index": {"2026": 300}}, + }, + "tax_units": { + "tax_unit_1": {"members": ["person_1"]}, + "tax_unit_2": {"members": ["person_2"]}, + "tax_unit_3": {"members": ["person_3"]}, + }, + "spm_units": { + "spm_unit_1": {"members": ["person_1"]}, + "spm_unit_2": {"members": ["person_2"]}, + "spm_unit_3": {"members": ["person_3"]}, + }, + "families": { + "family_1": {"members": ["person_1"]}, + "family_2": {"members": ["person_2"]}, + "family_3": {"members": ["person_3"]}, + }, + "households": { + "household_1": { + "members": ["person_1"], + "state_code": {"2026": "TX"}, + }, + "household_2": { + "members": ["person_2"], + "state_code": {"2026": "TX"}, + }, + "household_3": { + "members": ["person_3"], + "state_code": {"2026": "TX"}, + }, + }, + } + ) + simulation.is_over_dataset = True + simulation.set_input("person_weight", 2026, [1_000, 2_000, 1_500]) + simulation.set_input("medicaid_enrolled", 2026, [True, True, True]) + + weight = simulation.calculate("person_weight", 2026) + enrolled = simulation.calculate("medicaid_enrolled", 2026) + cost = simulation.calculate("medicaid_cost_if_enrolled", 2026) + total_allocated = np.sum(weight * enrolled * cost) + + spending_tx = simulation.tax_benefit_system.parameters( + "2026-01-01" + ).calibration.gov.hhs.medicaid.totals.spending.TX + + assert total_allocated == pytest.approx(spending_tx, rel=1e-6) + + +def test_medicaid_cost_if_enrolled_is_zero_when_denominator_is_zero(): + # With no enrollees in the state the over-dataset denominator is 0; the + # guard must return 0 rather than NaN/inf. + simulation = Simulation( + situation={ + "people": { + "person_1": {"medicaid_slcsp_cost_index": {"2026": 100}}, + "person_2": {"medicaid_slcsp_cost_index": {"2026": 200}}, + }, + "tax_units": { + "tax_unit_1": {"members": ["person_1"]}, + "tax_unit_2": {"members": ["person_2"]}, + }, + "spm_units": { + "spm_unit_1": {"members": ["person_1"]}, + "spm_unit_2": {"members": ["person_2"]}, + }, + "families": { + "family_1": {"members": ["person_1"]}, + "family_2": {"members": ["person_2"]}, + }, + "households": { + "household_1": { + "members": ["person_1"], + "state_code": {"2026": "TX"}, + }, + "household_2": { + "members": ["person_2"], + "state_code": {"2026": "TX"}, + }, + }, + } + ) + simulation.is_over_dataset = True + simulation.set_input("person_weight", 2026, [1_000, 2_000]) + simulation.set_input("medicaid_enrolled", 2026, [False, False]) + + denominator = simulation.calculate("medicaid_slcsp_state_denominator", 2026) + cost = simulation.calculate("medicaid_cost_if_enrolled", 2026) + + np.testing.assert_allclose(denominator, [0, 0]) + np.testing.assert_allclose(cost, [0, 0]) diff --git a/policyengine_us/tests/policy/baseline/gov/aca/slcsp/slcsp_age_curve_multiplier.yaml b/policyengine_us/tests/policy/baseline/gov/aca/slcsp/slcsp_age_curve_multiplier.yaml new file mode 100644 index 00000000000..71b067b4491 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/aca/slcsp/slcsp_age_curve_multiplier.yaml @@ -0,0 +1,38 @@ +- name: Case 1, default age curve for a child. + period: 2026-01 + input: + people: + person1: + age: 0 + households: + household: + members: [person1] + state_code: TX + output: + slcsp_age_curve_multiplier: 1 + +- name: Case 2, default age curve for a forty-year-old. + period: 2026-01 + input: + people: + person1: + age: 40 + households: + household: + members: [person1] + state_code: TX + output: + slcsp_age_curve_multiplier: 1.6706 + +- name: Case 3, New York uses a flat adult multiplier. + period: 2026-01 + input: + people: + person1: + age: 40 + households: + household: + members: [person1] + state_code: NY + output: + slcsp_age_curve_multiplier: 1 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/is_medicaid_slcsp_dependent_child.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/is_medicaid_slcsp_dependent_child.yaml new file mode 100644 index 00000000000..82da1a4d8ec --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/is_medicaid_slcsp_dependent_child.yaml @@ -0,0 +1,103 @@ +- name: Anyone at or under the SLCSP child age is a dependent child, dependent or not. + period: 2026 + input: + people: + young_child: + age: 10 + is_tax_unit_dependent: false + child_at_limit: + age: 20 + is_tax_unit_dependent: false + households: + household: + members: [young_child, child_at_limit] + output: + is_medicaid_slcsp_dependent_child: [true, true] + +- name: A non-dependent adult over the child age is not a dependent child. + period: 2026 + input: + people: + adult: + age: 21 + is_tax_unit_dependent: false + households: + household: + members: [adult] + output: + is_medicaid_slcsp_dependent_child: false + +- name: A tax dependent under 26 is a dependent child. + period: 2026 + input: + people: + dependent_21: + age: 21 + is_tax_unit_dependent: true + dependent_25: + age: 25 + is_tax_unit_dependent: true + households: + household: + members: [dependent_21, dependent_25] + output: + is_medicaid_slcsp_dependent_child: [true, true] + +- name: Outside New York a tax dependent aged 26 or older is not a dependent child. + period: 2026 + input: + people: + dependent_26: + age: 26 + is_tax_unit_dependent: true + households: + household: + members: [dependent_26] + state_code: TX + output: + is_medicaid_slcsp_dependent_child: false + +- name: New York counts tax dependents aged 26-29 as dependent children. + period: 2026 + input: + people: + ny_dependent_26: + age: 26 + is_tax_unit_dependent: true + ny_dependent_29: + age: 29 + is_tax_unit_dependent: true + households: + household: + members: [ny_dependent_26, ny_dependent_29] + state_code: NY + output: + is_medicaid_slcsp_dependent_child: [true, true] + +- name: New York stops counting dependents as children at age 30. + period: 2026 + input: + people: + ny_dependent_30: + age: 30 + is_tax_unit_dependent: true + households: + household: + members: [ny_dependent_30] + state_code: NY + output: + is_medicaid_slcsp_dependent_child: false + +- name: A non-dependent New Yorker aged 26-29 is not a dependent child. + period: 2026 + input: + people: + ny_adult_29: + age: 29 + is_tax_unit_dependent: false + households: + household: + members: [ny_adult_29] + state_code: NY + output: + is_medicaid_slcsp_dependent_child: false diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_cost_if_enrolled.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_cost_if_enrolled.yaml index 0cbd7d05a61..b600ffd8200 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_cost_if_enrolled.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_cost_if_enrolled.yaml @@ -1,23 +1,34 @@ -- name: Medicaid cost if enrolled is a data input. +- name: A lone Texas enrollee's cost equals state per-capita Medicaid spending. period: 2026 + absolute_error_margin: 1 input: - medicaid_cost_if_enrolled: 12_000 - output: - medicaid_cost_if_enrolled: 12_000 + people: + person1: + age: 40 + is_medicaid_eligible: true + takes_up_medicaid_if_eligible: true + households: + household: + members: [person1] + state_code: TX + output: + # For a single household the cost index cancels, leaving TX spending / enrollment; + # an enrolled person's medicaid_cost passes that computed value through unchanged. + medicaid_cost_if_enrolled: 13_708.50 + medicaid_cost: 13_708.50 -- name: Medicaid cost remains zero when not enrolled. +- name: Medicaid cost is zero for a non-enrollee despite a positive per-capita cost. period: 2026 input: - medicaid_cost_if_enrolled: 12_000 - is_medicaid_eligible: false - output: + people: + person1: + age: 40 + is_medicaid_eligible: false + households: + household: + members: [person1] + state_code: TX + output: + # medicaid_cost is defined_for medicaid_enrolled, so it stays zero off-enrollment + # even though medicaid_cost_if_enrolled itself computes a positive value. medicaid_cost: 0 - -- name: Medicaid cost uses input when enrolled. - period: 2026 - input: - medicaid_cost_if_enrolled: 12_000 - is_medicaid_eligible: true - takes_up_medicaid_if_eligible: true - output: - medicaid_cost: 12_000 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_cost_index.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_cost_index.yaml new file mode 100644 index 00000000000..1f7494eee3b --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_cost_index.yaml @@ -0,0 +1,44 @@ +- name: New York family applies the family-tier share to the cost index. + period: 2026 + absolute_error_margin: 0.1 + input: + people: + adult1: + age: 40 + adult2: + age: 38 + child1: + age: 10 + is_tax_unit_dependent: true + child2: + age: 8 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [adult1, adult2, child1, child2] + households: + household: + members: [adult1, adult2, child1, child2] + state_code: NY + slcsp_age_0: + 2026-01: 1_000 + output: + # Two-adults-with-children NY multiplier (2.85) x base 1,000, split over 4. + medicaid_slcsp_cost_index: 712.5 + +- name: Texas individual uses the age-rated index. + period: 2026 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 40 + households: + household: + members: [person1] + state_code: TX + slcsp_age_0: + 2026-01: 1_000 + output: + # Base 1,000 x default age-40 curve multiplier (1.6706). + medicaid_slcsp_cost_index: 1_670.6 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_cost_index_filled.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_cost_index_filled.yaml new file mode 100644 index 00000000000..cc633e4e16a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_cost_index_filled.yaml @@ -0,0 +1,38 @@ +- name: Household with a missing premium is filled with the state-average index. + period: 2026 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 40 + person2: + age: 40 + person3: + age: 40 + tax_units: + tax_unit1: + members: [person1] + tax_unit2: + members: [person2] + tax_unit3: + members: [person3] + households: + # Missing SLCSP premium data -> zero index, filled with the state average. + household1: + members: [person1] + state_code: TX + slcsp_age_0: + 2026-01: 0 + household2: + members: [person2] + state_code: TX + slcsp_age_0: + 2026-01: 1_000 + household3: + members: [person3] + state_code: TX + slcsp_age_0: + 2026-01: 2_000 + output: + # person1 filled with mean of the two positive indices (1,670.6, 3,341.2). + medicaid_slcsp_cost_index_filled: [2_505.9, 1_670.6, 3_341.2] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_category.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_category.yaml new file mode 100644 index 00000000000..9ce9e964e4f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_category.yaml @@ -0,0 +1,71 @@ +- name: Case 1, New York child-only unit uses child-only tier. + period: 2026 + input: + people: + person1: + age: 10 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: NY + output: + medicaid_slcsp_family_tier_category: CHILD_ONLY + +- name: Case 2, Vermont child-only unit remains age rated. + period: 2026 + input: + people: + person1: + age: 10 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: VT + output: + medicaid_slcsp_family_tier_category: INDIVIDUAL_AGE_RATED + +- name: Case 3, New York adult with child uses one-adult-with-children tier. + period: 2026 + input: + people: + person1: + age: 35 + person2: + age: 10 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: NY + output: + medicaid_slcsp_family_tier_category: ONE_ADULT_AND_ONE_OR_MORE_CHILDREN + +- name: Case 4, New York counts an age-29 tax dependent as a child, not a second adult. + period: 2026 + input: + people: + person1: + age: 50 + person2: + age: 29 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: NY + output: + medicaid_slcsp_family_tier_category: ONE_ADULT_AND_ONE_OR_MORE_CHILDREN diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_multiplier.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_multiplier.yaml new file mode 100644 index 00000000000..760124fcdad --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_multiplier.yaml @@ -0,0 +1,100 @@ +- name: New York two adults take the two-adult multiplier. + period: 2026 + input: + people: + person1: + age: 35 + person2: + age: 35 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: NY + output: + medicaid_slcsp_family_tier_multiplier: 2 + +- name: Vermont one adult with a child takes the one-adult-with-children multiplier. + period: 2026 + input: + people: + person1: + age: 35 + person2: + age: 10 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: VT + output: + medicaid_slcsp_family_tier_multiplier: 1.93 + +- name: New York three adults add the one-adult multiplier for each extra adult. + period: 2026 + input: + people: + person1: + age: 35 + person2: + age: 35 + person3: + age: 35 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: NY + output: + medicaid_slcsp_family_tier_multiplier: 3 + +- name: New York applies the age-29 dependent loading to the family tier multiplier. + period: 2026 + input: + people: + person1: + age: 35 + person2: + age: 35 + person3: + age: 29 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: NY + output: + # Two adults + one age-29 dependent child: 2.85 x 1.05 loading. + medicaid_slcsp_family_tier_multiplier: 2.9925 + +- name: New York does not apply the age-29 loading for a younger dependent child. + period: 2026 + input: + people: + person1: + age: 35 + person2: + age: 35 + person3: + age: 10 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: NY + output: + # Two adults + one young child: 2.85 with no loading. + medicaid_slcsp_family_tier_multiplier: 2.85 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_person_share.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_person_share.yaml new file mode 100644 index 00000000000..6c4ec3c9217 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_person_share.yaml @@ -0,0 +1,44 @@ +- name: New York two adults split the family-tier amount across the unit. + period: 2026 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + person2: + age: 35 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: NY + slcsp_age_0: + 2026-01: 1_000 + output: + # Two-adults NY multiplier (2.0) x base 1,000, split over 2. + medicaid_slcsp_family_tier_person_share: 1_000 + +- name: Vermont adult with a child splits the family-tier amount across the unit. + period: 2026 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + person2: + age: 10 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: VT + slcsp_age_0: + 2026-01: 1_000 + output: + # One-adult-with-children VT multiplier (1.93) x base 1,000, split over 2. + medicaid_slcsp_family_tier_person_share: 965 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_state_average_cost_index.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_state_average_cost_index.yaml new file mode 100644 index 00000000000..ad898b2d935 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_state_average_cost_index.yaml @@ -0,0 +1,43 @@ +- name: State average is weighted, excludes missing premiums, and is per state. + period: 2026 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 40 + person_weight: 1 + person2: + age: 40 + person_weight: 3 + # Missing premium -> zero index, excluded from the average despite weight. + person3: + age: 40 + person_weight: 5 + person4: + age: 40 + person_weight: 1 + households: + household1: + members: [person1] + state_code: TX + slcsp_age_0: + 2026-01: 1_000 + household2: + members: [person2] + state_code: TX + slcsp_age_0: + 2026-01: 2_000 + household3: + members: [person3] + state_code: TX + slcsp_age_0: + 2026-01: 0 + household4: + members: [person4] + state_code: NY + slcsp_age_0: + 2026-01: 1_000 + output: + # TX average of the two positive indices, weighted 1 and 3, broadcast to all + # TX residents (including the missing-premium person); NY is separate. + medicaid_slcsp_state_average_cost_index: [2_923.55, 2_923.55, 2_923.55, 1_000] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_state_denominator.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_state_denominator.yaml new file mode 100644 index 00000000000..6cffc0f837a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/costs/medicaid_slcsp_state_denominator.yaml @@ -0,0 +1,33 @@ +- name: Single household scales state enrollment by the state-average index. + period: 2026 + absolute_error_margin: 1_000 + input: + people: + person1: + age: 40 + households: + household: + members: [person1] + state_code: TX + slcsp_age_0: + 2026-01: 1_000 + output: + # TX enrollment (4,178,724) x the household's index (1,670.6). + medicaid_slcsp_state_denominator: 6_980_976_640 + +- name: Missing premium falls back to a unit index, so the denominator stays positive. + period: 2026 + absolute_error_margin: 1 + input: + people: + person1: + age: 40 + households: + household: + members: [person1] + state_code: TX + slcsp_age_0: + 2026-01: 0 + output: + # No positive index -> state average falls back to 1, so denominator = TX enrollment. + medicaid_slcsp_state_denominator: 4_178_724 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/medicaid_enrolled.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/medicaid_enrolled.yaml index 0a90693eb46..6efcba2e39e 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/medicaid_enrolled.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/medicaid_enrolled.yaml @@ -4,6 +4,7 @@ - name: Case 1, undocumented adult in CA 2026 existing enrollee retains coverage. period: 2026 + absolute_error_margin: 0.1 input: people: person1: @@ -11,7 +12,6 @@ employment_income: 15_000 immigration_status: UNDOCUMENTED receives_medicaid: true - medicaid_cost_if_enrolled: 6_439.11 tax_units: tax_unit: members: [person1] @@ -29,7 +29,8 @@ is_ca_medicaid_immigration_status_eligible: [true] is_medicaid_eligible: [true] medicaid_enrolled: [true] - medicaid: [6_439.11] + # Computed CA per-capita cost under the SLCSP-index allocation (not injected). + medicaid: [9_236.48] - name: Case 2, undocumented adult in CA 2024 enrolled and eligible. period: 2024 diff --git a/policyengine_us/variables/gov/aca/slspc/slcsp_age_curve_amount_person.py b/policyengine_us/variables/gov/aca/slspc/slcsp_age_curve_amount_person.py index 27c126b3e4e..6cd19b7f68b 100644 --- a/policyengine_us/variables/gov/aca/slspc/slcsp_age_curve_amount_person.py +++ b/policyengine_us/variables/gov/aca/slspc/slcsp_age_curve_amount_person.py @@ -10,33 +10,7 @@ class slcsp_age_curve_amount_person(Variable): defined_for = "pays_aca_premium" def formula(person, period, parameters): - state_code = person.household("state_code_str", period) - age = person("monthly_age", period) base_cost = person.household("slcsp_age_0", period) - - p = parameters(period).gov.aca.age_curves - - # Handle other states with regular bracket structures - multiplier = select( - [ - state_code == "AL", - state_code == "DC", - state_code == "MA", - state_code == "MN", - state_code == "MS", - state_code == "OR", - state_code == "UT", - ], - [ - p.al.calc(age), - p.dc.calc(age), - p.ma.calc(age), - p.mn.calc(age), - p.ms.calc(age), - p["or"].calc(age), - p.ut.calc(age), - ], - default=p.default.calc(age), - ) + multiplier = person("slcsp_age_curve_multiplier", period) age_curve_applies = person.tax_unit("slcsp_age_curve_applies", period) return base_cost * multiplier * age_curve_applies diff --git a/policyengine_us/variables/gov/aca/slspc/slcsp_age_curve_multiplier.py b/policyengine_us/variables/gov/aca/slspc/slcsp_age_curve_multiplier.py new file mode 100644 index 00000000000..a69d969b28d --- /dev/null +++ b/policyengine_us/variables/gov/aca/slspc/slcsp_age_curve_multiplier.py @@ -0,0 +1,42 @@ +from policyengine_us.model_api import * + + +class slcsp_age_curve_multiplier(Variable): + value_type = float + entity = Person + label = "ACA SLCSP age curve multiplier" + unit = "/1" + definition_period = MONTH + + def formula(person, period, parameters): + state_code = person.household("state_code_str", period) + age = person("monthly_age", period) + p = parameters(period).gov.aca.age_curves + + return select( + [ + state_code == "AL", + state_code == "DC", + state_code == "MA", + state_code == "MN", + state_code == "MS", + state_code == "NY", + state_code == "OR", + state_code == "UT", + state_code == "VT", + ], + [ + p.al.calc(age), + p.dc.calc(age), + p.ma.calc(age), + p.mn.calc(age), + p.ms.calc(age), + p.ny.calc(age), + p["or"].calc(age), + p.ut.calc(age), + # VT has no age variation: vt is a flat scalar, not an age scale, + # so it is read directly rather than via .calc(age). + p.vt, + ], + default=p.default.calc(age), + ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/costs/is_medicaid_slcsp_dependent_child.py b/policyengine_us/variables/gov/hhs/medicaid/costs/is_medicaid_slcsp_dependent_child.py new file mode 100644 index 00000000000..66c4f20d854 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/costs/is_medicaid_slcsp_dependent_child.py @@ -0,0 +1,34 @@ +from policyengine_us.model_api import * + + +class is_medicaid_slcsp_dependent_child(Variable): + value_type = bool + entity = Person + label = "Person is a dependent child for Medicaid SLCSP cost indexing" + definition_period = YEAR + documentation = ( + "Child definition for assigning Medicaid enrollees to ACA family tiers. " + "Kept separate from is_aca_family_tier_dependent_child because that " + "variable gates on pays_aca_premium, which is false for Medicaid " + "enrollees and would classify them all as non-children." + ) + + def formula(person, period, parameters): + p = parameters(period).gov.aca + age = person("age", period) + is_tax_dependent = person("is_tax_unit_dependent", period) + state_code = person.household("state_code", period) + in_ny = state_code == state_code.possible_values.NY + # New York extends dependent-child status to tax dependents aged 26-29, + # mirroring is_aca_ny_age_29_dependent_child (minus the premium gate). + ny_age_29_child = ( + in_ny + & is_tax_dependent + & (age >= p.family_tier_dependent_child_age_threshold) + & (age < p.ny_age_29_dependent_child_age_threshold) + ) + return ( + (age <= p.slcsp.max_child_age) + | (is_tax_dependent & (age < p.family_tier_dependent_child_age_threshold)) + | ny_age_29_child + ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_cost_if_enrolled.py b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_cost_if_enrolled.py index fc9212d1c60..b40df363323 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_cost_if_enrolled.py +++ b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_cost_if_enrolled.py @@ -7,3 +7,16 @@ class medicaid_cost_if_enrolled(Variable): label = "Medicaid cost if enrolled" unit = USD definition_period = YEAR + + def formula(person, period, parameters): + state_code = person.household("state_code", period) + spending = parameters(period).calibration.gov.hhs.medicaid.totals.spending + cost_index = person("medicaid_slcsp_cost_index_filled", period) + denominator = person("medicaid_slcsp_state_denominator", period) + + return np.divide( + spending[state_code] * cost_index, + denominator, + out=np.zeros_like(denominator), + where=denominator > 0, + ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_cost_index.py b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_cost_index.py new file mode 100644 index 00000000000..7aef1a068b4 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_cost_index.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class medicaid_slcsp_cost_index(Variable): + value_type = float + entity = Person + label = "Medicaid SLCSP cost index" + unit = USD + definition_period = YEAR + + def formula(person, period, parameters): + month = period.first_month + base_cost = person.household("slcsp_age_0", month) + # Re-derived here rather than reusing slcsp_age_curve_amount_person, + # which gates on pays_aca_premium (false for Medicaid enrollees). + age_rated_index = base_cost * person("slcsp_age_curve_multiplier", month) + family_tier_share = person.tax_unit( + "medicaid_slcsp_family_tier_person_share", period + ) + # Family-tier share where it applies, else the age-rated index. Outer + # max_ is a defensive floor against a negative parameter. + return max_(where(family_tier_share > 0, family_tier_share, age_rated_index), 0) diff --git a/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_cost_index_filled.py b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_cost_index_filled.py new file mode 100644 index 00000000000..e09a8aa1cfa --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_cost_index_filled.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * + + +class medicaid_slcsp_cost_index_filled(Variable): + value_type = float + entity = Person + label = "Medicaid SLCSP cost index with state fallback" + unit = USD + definition_period = YEAR + + def formula(person, period, parameters): + cost_index = person("medicaid_slcsp_cost_index", period) + state_average = person("medicaid_slcsp_state_average_cost_index", period) + return where(cost_index > 0, cost_index, state_average) diff --git a/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_category.py b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_category.py new file mode 100644 index 00000000000..11f813ef75e --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_category.py @@ -0,0 +1,46 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.aca.slspc.slcsp_family_tier_category import ( + FamilyTierCategory, +) + + +class medicaid_slcsp_family_tier_category(Variable): + value_type = Enum + entity = TaxUnit + possible_values = FamilyTierCategory + default_value = FamilyTierCategory.INDIVIDUAL_AGE_RATED + definition_period = YEAR + label = "Medicaid SLCSP family tier category" + + def formula(tax_unit, period, parameters): + state = tax_unit.household("state_code_str", period) + family_tier_applies = tax_unit("slcsp_family_tier_applies", period.first_month) + + child_count = tax_unit.sum( + tax_unit.members("is_medicaid_slcsp_dependent_child", period) + ) + adult_count = tax_unit("tax_unit_size", period) - child_count + + one_adult_no_children = (adult_count == 1) & (child_count == 0) + two_plus_adults_no_children = (adult_count >= 2) & (child_count == 0) + one_adult_with_children = (adult_count == 1) & (child_count > 0) + two_plus_adults_with_children = (adult_count >= 2) & (child_count > 0) + ny_child_only = (state == "NY") & (adult_count == 0) & (child_count > 0) + + return select( + [ + ny_child_only, + family_tier_applies & one_adult_no_children, + family_tier_applies & two_plus_adults_no_children, + family_tier_applies & one_adult_with_children, + family_tier_applies & two_plus_adults_with_children, + ], + [ + FamilyTierCategory.CHILD_ONLY, + FamilyTierCategory.ONE_ADULT, + FamilyTierCategory.TWO_ADULTS, + FamilyTierCategory.ONE_ADULT_AND_ONE_OR_MORE_CHILDREN, + FamilyTierCategory.TWO_ADULTS_AND_ONE_OR_MORE_CHILDREN, + ], + default=FamilyTierCategory.INDIVIDUAL_AGE_RATED, + ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_multiplier.py b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_multiplier.py new file mode 100644 index 00000000000..c5e63ea3e03 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_multiplier.py @@ -0,0 +1,81 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.aca.slspc.slcsp_family_tier_category import ( + FamilyTierCategory, +) + + +class medicaid_slcsp_family_tier_multiplier(Variable): + value_type = float + entity = TaxUnit + label = "Medicaid SLCSP family tier multiplier" + unit = "/1" + definition_period = YEAR + + def formula(tax_unit, period, parameters): + family_category = tax_unit("medicaid_slcsp_family_tier_category", period) + p = parameters(period).gov.aca + state_code = tax_unit.household("state_code", period) + in_ny = state_code == state_code.possible_values.NY + + ratings = p.family_tier_ratings + one_adult = where(in_ny, ratings.ny.ONE_ADULT, ratings.vt.ONE_ADULT) + two_adults = where(in_ny, ratings.ny.TWO_ADULTS, ratings.vt.TWO_ADULTS) + one_adult_with_children = where( + in_ny, + ratings.ny.ONE_ADULT_AND_ONE_OR_MORE_CHILDREN, + ratings.vt.ONE_ADULT_AND_ONE_OR_MORE_CHILDREN, + ) + two_adults_with_children = where( + in_ny, + ratings.ny.TWO_ADULTS_AND_ONE_OR_MORE_CHILDREN, + ratings.vt.TWO_ADULTS_AND_ONE_OR_MORE_CHILDREN, + ) + child_only = where(in_ny, ratings.ny.CHILD_ONLY, 0) + + base_multiplier = select( + [ + family_category == FamilyTierCategory.ONE_ADULT, + family_category == FamilyTierCategory.TWO_ADULTS, + family_category + == FamilyTierCategory.ONE_ADULT_AND_ONE_OR_MORE_CHILDREN, + family_category + == FamilyTierCategory.TWO_ADULTS_AND_ONE_OR_MORE_CHILDREN, + family_category == FamilyTierCategory.CHILD_ONLY, + ], + [ + one_adult, + two_adults, + one_adult_with_children, + two_adults_with_children, + child_only, + ], + default=0, + ) + + child_count = tax_unit.sum( + tax_unit.members("is_medicaid_slcsp_dependent_child", period) + ) + adult_count = tax_unit("tax_unit_size", period) - child_count + family_tier_applies = family_category != FamilyTierCategory.INDIVIDUAL_AGE_RATED + extra_adults = where(family_tier_applies, max_(adult_count - 2, 0), 0) + # New York applies a premium loading when the unit covers a tax dependent + # aged 26-29 (mirrors slcsp_family_tier_multiplier, minus the + # pays_aca_premium gate that is false for Medicaid enrollees). + members = tax_unit.members + member_age = members("age", period) + member_is_dependent = members("is_tax_unit_dependent", period) + member_in_ny = ( + members.household("state_code", period) == state_code.possible_values.NY + ) + ny_age_29_child = ( + member_in_ny + & member_is_dependent + & (member_age >= p.family_tier_dependent_child_age_threshold) + & (member_age < p.ny_age_29_dependent_child_age_threshold) + ) + age_29_multiplier = where( + tax_unit.any(ny_age_29_child), + p.ny_age_29_dependent_child_tier_multiplier, + 1, + ) + return base_multiplier * age_29_multiplier + extra_adults * one_adult diff --git a/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_person_share.py b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_person_share.py new file mode 100644 index 00000000000..3ed14578f88 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_family_tier_person_share.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * + + +class medicaid_slcsp_family_tier_person_share(Variable): + value_type = float + entity = TaxUnit + label = "Medicaid SLCSP family-tier person share" + unit = USD + definition_period = YEAR + + def formula(tax_unit, period, parameters): + base_cost = tax_unit.household("slcsp_age_0", period.first_month) + multiplier = tax_unit("medicaid_slcsp_family_tier_multiplier", period) + tax_unit_size = tax_unit("tax_unit_size", period) + + return np.divide( + base_cost * multiplier, + tax_unit_size, + out=np.zeros_like(base_cost), + where=tax_unit_size > 0, + ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_state_average_cost_index.py b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_state_average_cost_index.py new file mode 100644 index 00000000000..feeafab1734 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_state_average_cost_index.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.hhs.medicaid.costs.state_aggregate_helpers import ( + sum_by_state, +) + + +class medicaid_slcsp_state_average_cost_index(Variable): + value_type = float + entity = Person + label = "Average Medicaid SLCSP cost index in each state" + unit = USD + definition_period = YEAR + + def formula(person, period, parameters): + state = person.household("state_code_str", period) + cost_index = person("medicaid_slcsp_cost_index", period) + weight = person("person_weight", period) + positive_index = cost_index > 0 + state_weight = sum_by_state(weight * positive_index, state) + state_cost_index = sum_by_state(weight * cost_index * positive_index, state) + + # Fall back to 1 (not 0) when a state has no positive-index weight, so + # the household denominator stays strictly positive. + return np.divide( + state_cost_index, + state_weight, + out=np.ones_like(cost_index), + where=state_weight > 0, + ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_state_denominator.py b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_state_denominator.py new file mode 100644 index 00000000000..f79d3f09f35 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/costs/medicaid_slcsp_state_denominator.py @@ -0,0 +1,33 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.hhs.medicaid.costs.state_aggregate_helpers import ( + sum_by_state, +) + + +class medicaid_slcsp_state_denominator(Variable): + value_type = float + entity = Person + label = "Medicaid SLCSP state allocation denominator" + unit = USD + definition_period = YEAR + + def formula(person, period, parameters): + simulation = person.simulation + if simulation.baseline is not None and simulation.branch_name != "baseline": + baseline_person = simulation.baseline.populations["person"] + return baseline_person("medicaid_slcsp_state_denominator", period) + + state_code = person.household("state_code", period) + if simulation.is_over_dataset: + state = person.household("state_code_str", period) + cost_index = person("medicaid_slcsp_cost_index_filled", period) + weight = person("person_weight", period) + enrolled = person("medicaid_enrolled", period) + return sum_by_state(weight * cost_index * enrolled, state) + + # Single household: no population to sum, so estimate the denominator + # as state enrollment times the state-average index. + p = parameters(period).calibration.gov.hhs.medicaid.totals + return p.enrollment[state_code] * person( + "medicaid_slcsp_state_average_cost_index", period + ) diff --git a/policyengine_us/variables/gov/hhs/medicaid/costs/state_aggregate_helpers.py b/policyengine_us/variables/gov/hhs/medicaid/costs/state_aggregate_helpers.py new file mode 100644 index 00000000000..eca698df386 --- /dev/null +++ b/policyengine_us/variables/gov/hhs/medicaid/costs/state_aggregate_helpers.py @@ -0,0 +1,26 @@ +"""Helper functions for aggregating person-level values by state. + +This module provides shared state-aggregation logic used by: +- medicaid_slcsp_state_average_cost_index (state-average cost index) +- medicaid_slcsp_state_denominator (state allocation denominator) +""" + +from policyengine_us.model_api import * + + +def sum_by_state(values, state): + """Broadcast each person to the total of ``values`` within their state. + + Args: + values: Person-level array to sum within each state. + state: Person-level array of state codes, same length as ``values``. + + Returns: + Array the same shape as ``values`` where each person holds the sum of + ``values`` over everyone sharing their state code. + """ + result = np.zeros_like(values, dtype=float) + for state_code in np.unique(state): + in_state = state == state_code + result[in_state] = np.sum(values[in_state]) + return result