diff --git a/changelog.d/dc-liheap-cooling.fixed.md b/changelog.d/dc-liheap-cooling.fixed.md new file mode 100644 index 00000000000..be539bc04af --- /dev/null +++ b/changelog.d/dc-liheap-cooling.fixed.md @@ -0,0 +1 @@ +DC LIHEAP Regular benefit now pays households with no heating cost that pay for cooling, from the Electric row capped at their electricity bill. DC LIHEAP income limits now use HHS's 60% state median income amounts, rounded down by household size, as DOEE publishes them. diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index 0f74596fd68..f0b10e4bfa7 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1144,6 +1144,7 @@ programs: full_name: DC Low Income Home Energy Assistance Program variable: dc_liheap_payment parameter_prefix: gov.states.dc.doee.liheap + notes: One-time Regular benefit from the DOEE matrix; it also covers cooling-only households, paying the Electric row capped at the electricity bill. - state: DE status: partial name: Delaware LIHEAP diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/doee/liheap/dc_liheap_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/doee/liheap/dc_liheap_eligible.yaml index a4b65bf8b07..9bea9aea433 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/dc/doee/liheap/dc_liheap_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/doee/liheap/dc_liheap_eligible.yaml @@ -6,7 +6,7 @@ state_code: DC output: hhs_smi: 152_043 - # income limit = 152_043 * 0.6 = 91,225.8 + # income limit = 152_043 * 0.6 = 91,225.8, rounded down to 91,225 dc_liheap_eligible: true - name: Case 2, household size of 4, ineligible. @@ -28,12 +28,13 @@ # 42 U.S.C. 8624(b)(2)(B) bars excluding a household on income below 110% # of the poverty guideline. FY2024 uses the 2023 guidelines (LIHEAP IM # 2023-01): 14,580 + 5,140 x 35 = 194,480 for 36 people, and 110% is - # 213,928. The 60% SMI limit for 36 people is 212,669.78. + # 213,928. The 60% SMI limit for 36 people is 159,662 x 0.6 = 95,797 + # (rounded down) x 2.22 = 212,669 (rounded down). # Household 1 is below the 60% SMI limit: eligible. # Household 2 is one cent below the floor: eligible. # Household 3 is exactly at the floor, which is not below 110%: not eligible. # Household 4 is above the floor: not eligible. - # Household 5 has 35 people, whose 209,795.87 limit exceeds the 208,274 + # Household 5 has 35 people, whose 209,795 limit exceeds the 208,274 # floor (110% of 189,340), so the limit still applies: not eligible. dc_liheap_eligible: [true, true, false, false, false] @@ -56,5 +57,17 @@ irs_gross_income: [212_244.99, 212_245] output: # 110% x (13,590 + 4,720 x 38) = 212,245 for 39 people, above the - # 210,731.60 FY2023 60% SMI limit. + # 210,729 FY2023 60% SMI limit (91,225 x 2.31, rounded down). + dc_liheap_eligible: [true, false] + +- name: Case 6, the income limit is HHS's 60% state median income amount, rounded down. + period: 2026 + input: + state_code: [DC, DC] + spm_unit_size: [2, 2] + irs_gross_income: [80_869, 80_870] + output: + # FY2026: 198,211 x 0.6 = 118,926 (rounded down) x 0.68 = 80,869 (rounded + # down), DOEE's published two-person limit. The unrounded 80,870.09 would + # admit $80,870. dc_liheap_eligible: [true, false] diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/doee/liheap/dc_liheap_payment.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/doee/liheap/dc_liheap_payment.yaml index 3eea7f9ce34..2a6008657d0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/dc/doee/liheap/dc_liheap_payment.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/doee/liheap/dc_liheap_payment.yaml @@ -337,3 +337,272 @@ # canonical heating_type (gas bill 1_400): min(1_500, 1_400) = 1_400 dc_liheap_heating_type: OIL dc_liheap_payment: 1_400 + +# Cooling-only households, FY2026 matrix (period 2026). DOEE's one-time Regular +# benefit helps with heating and cooling costs (DOEE LIHEAP page; FAQ Q3), and +# the matrix amount is a maximum benefit. A home without heating that pays its +# own electricity bill for cooling takes the Electric row, capped at that bill. +# Income levels use 5_000 steps (level 2 up to 10_000, level 3 up to 15_000). +# Wages are at the DC minimum wage of 17.95 an hour from July 1, 2025: +# 15 hours a week is 15 x 52 x 17.95 = 14_001 and 5 hours is 4_667; full time +# at 40.00 an hour is 83_200. Electricity of 1_358.76 a year is EIA's 2024 DC +# average residential bill of 113.23 a month (Table 5A, +# https://www.eia.gov/electricity/sales_revenue_price/pdf/table_5A.pdf). +- name: Cooling only, a home without heating takes the electricity row. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 14_001 + spm_units: + spm_unit: + members: [person1] + heating_type: NONE + has_cooling_expense: true + pre_subsidy_electricity_expense: 1_358.76 + dc_liheap_housing_type: SINGLE_FAMILY + households: + household: + members: [person1] + state_code: DC + output: + dc_liheap_heating_type: ELECTRICITY + # 10_000 < 14_001 <= 15_000 + dc_liheap_income_level: 3 + # SF, level 3, one person: Electric 440; min(440, 1_358.76) = 440 + dc_liheap_payment: 440 + +- name: Cooling only, the electricity bill caps the electricity row. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 4_667 + person2: + age: 38 + spm_units: + spm_unit: + members: [person1, person2] + heating_type: NONE + has_cooling_expense: true + pre_subsidy_electricity_expense: 1_358.76 + dc_liheap_housing_type: SINGLE_FAMILY + households: + household: + members: [person1, person2] + state_code: DC + output: + # 4_667 <= 5_000 + dc_liheap_income_level: 1 + # SF, level 1, two people: Electric 1_481; min(1_481, 1_358.76) = 1_358.76 + dc_liheap_payment: 1_358.76 + +- name: No heating and no cooling costs pays nothing despite an electricity bill. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 14_001 + spm_units: + spm_unit: + members: [person1] + heating_type: NONE + has_cooling_expense: false + pre_subsidy_electricity_expense: 1_358.76 + dc_liheap_housing_type: SINGLE_FAMILY + households: + household: + members: [person1] + state_code: DC + output: + # NONE without cooling: heating_expense = 0; min(440, 0) = 0 + dc_liheap_payment: 0 + +- name: Cooling only, a heated home with no heating cost takes the electricity row. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 14_001 + spm_units: + spm_unit: + members: [person1] + # Wood the household gathers itself, so there is no heating bill. + heating_type: WOOD + has_cooling_expense: true + pre_subsidy_electricity_expense: 1_358.76 + dc_liheap_housing_type: SINGLE_FAMILY + households: + household: + members: [person1] + state_code: DC + output: + # DOEE sets no fuel condition on the benefit, and its only energy cost is + # cooling. SF, level 3, one person: Electric 440; min(440, 1_358.76) = 440. + dc_liheap_payment: 440 + +- name: Cooling only, a gas-heated home with no gas bill takes the electricity amount, not its gas row. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 14_001 + spm_units: + spm_unit: + members: [person1] + # Gas heat with no gas bill of its own, so there is no heating cost. + heating_type: NATURAL_GAS + has_cooling_expense: true + pre_subsidy_electricity_expense: 1_358.76 + dc_liheap_housing_type: SINGLE_FAMILY + households: + household: + members: [person1] + state_code: DC + output: + dc_liheap_heating_type: GAS + # Its only energy cost is cooling, so it is paid the Electric amount, not + # the Gas row (1_193) against the electricity bill. SF, level 3, one + # person: Electric 440; min(440, 1_358.76) = 440. + dc_liheap_payment: 440 + +- name: A heated home with a heating bill and a cooling cost keeps its heating row. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 14_001 + spm_units: + spm_unit: + members: [person1] + heating_type: NATURAL_GAS + has_cooling_expense: true + gas_expense: 300 + pre_subsidy_electricity_expense: 1_358.76 + dc_liheap_housing_type: SINGLE_FAMILY + households: + household: + members: [person1] + state_code: DC + output: + # SF, level 3, one person: Gas 1_193, capped at the 300 heating bill. The + # cooling cost does not move it to the electricity bill. + dc_liheap_payment: 300 + +- name: A heat-in-rent tenant with a cooling cost keeps the flat heat-in-rent amount. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 14_001 + spm_units: + spm_unit: + members: [person1] + heating_type: NATURAL_GAS + heat_expense_included_in_rent: true + has_cooling_expense: true + pre_subsidy_electricity_expense: 1_358.76 + dc_liheap_housing_type: SINGLE_FAMILY + households: + household: + members: [person1] + state_code: DC + output: + dc_liheap_heating_type: HEAT_IN_RENT + # Heat in the rent keeps the household off the cooling route: it gets the + # flat heat-in-rent amount of 200, with no expense cap, not the Electric + # amount of 440. + dc_liheap_payment: 200 + +- name: Cooling only, income above the limit pays nothing. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 83_200 + spm_units: + spm_unit: + members: [person1] + heating_type: NONE + has_cooling_expense: true + pre_subsidy_electricity_expense: 1_358.76 + dc_liheap_housing_type: SINGLE_FAMILY + households: + household: + members: [person1] + state_code: DC + output: + # 83_200 exceeds 60% of the one-person state median income + dc_liheap_eligible: false + dc_liheap_payment: 0 + +# The cooling route needs a known heating type. An unspecified fuel keeps the +# legacy adapter even with a cooling cost: the person-level heating total of +# 300 (as in the legacy precedence case above) caps the Electric row, not the +# electricity bill. +- name: Cooling cost with an unspecified fuel keeps the legacy adapter. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 14_001 + heating_expense_person: 300 + spm_units: + spm_unit: + members: [person1] + heating_type: UNSPECIFIED + has_cooling_expense: true + pre_subsidy_electricity_expense: 1_358.76 + dc_liheap_housing_type: SINGLE_FAMILY + households: + household: + members: [person1] + state_code: DC + output: + dc_liheap_heating_type: ELECTRICITY + # SF, level 3, one person: Electric 440; legacy expense 300; min(440, 300) = 300 + dc_liheap_payment: 300 + +- name: Cooling cost with an unspecified fuel on the deprecated gas row and no gas bill pays nothing. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 14_001 + spm_units: + spm_unit: + members: [person1] + heating_type: UNSPECIFIED + dc_liheap_heating_type: GAS + has_cooling_expense: true + pre_subsidy_electricity_expense: 1_358.76 + dc_liheap_housing_type: SINGLE_FAMILY + households: + household: + members: [person1] + state_code: DC + output: + # An unspecified fuel stays off the cooling route. The legacy adapter takes + # the gas bill for the direct GAS row, which is 0: min(1_193, 0) = 0, as on + # main. + dc_liheap_payment: 0 diff --git a/policyengine_us/variables/gov/states/dc/doee/liheap/dc_liheap_eligible.py b/policyengine_us/variables/gov/states/dc/doee/liheap/dc_liheap_eligible.py index 0a0bca7c37e..b28b105afe5 100644 --- a/policyengine_us/variables/gov/states/dc/doee/liheap/dc_liheap_eligible.py +++ b/policyengine_us/variables/gov/states/dc/doee/liheap/dc_liheap_eligible.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.hhs.hhs_smi import liheap_smi_limit from policyengine_us.variables.gov.hhs.tax_unit_fpg import fpg @@ -10,20 +11,24 @@ class dc_liheap_eligible(Variable): defined_for = StateCode.DC reference = ( "https://doee.dc.gov/liheap", + # FY2026 federal amounts + "https://acf.gov/sites/default/files/documents/ocs/COMM_LIHEAP_IM2025-02_SMIStateTable_Att4.pdf#page=1", "https://www.law.cornell.edu/uscode/text/42/8624", ) def formula(spm_unit, period, parameters): p = parameters(period).gov.hhs.liheap p_dc = parameters(period).gov.states.dc.doee.liheap - state_median_income = spm_unit("hhs_smi", period) # The income concept is not clearly defined, assuming IRS gross income income = add(spm_unit, period, ["irs_gross_income"]) - smi_limit = state_median_income * p.smi_limit + size = spm_unit("spm_unit_size", period) + state = spm_unit.household("state_code_str", period) + # HHS's 60% state median income amounts, rounded down by household + # size, match DOEE's published limits (FY2026: $80,869 for two people). + smi_limit = liheap_smi_limit(size, state, p.smi_limit, period, parameters) # 42 U.S.C. 8624(b)(2)(B) bars excluding a household on income below 110% # of the poverty guideline. The 60% SMI limit falls below that floor from # household size 34 to 39 in 2022 to 2024. - size = spm_unit("spm_unit_size", period) state_group = spm_unit.household("state_group_str", period) guideline = fpg( size, state_group, period, parameters, year_lag=p_dc.fpg_year_lag diff --git a/policyengine_us/variables/gov/states/dc/doee/liheap/dc_liheap_heating_type.py b/policyengine_us/variables/gov/states/dc/doee/liheap/dc_liheap_heating_type.py index 7d4a9be3bd5..2141e059a6e 100644 --- a/policyengine_us/variables/gov/states/dc/doee/liheap/dc_liheap_heating_type.py +++ b/policyengine_us/variables/gov/states/dc/doee/liheap/dc_liheap_heating_type.py @@ -15,7 +15,7 @@ class dc_liheap_heating_type(Variable): default_value = DCLIHEAPHeatingType.ELECTRICITY definition_period = YEAR label = "Household heating types for DC LIHEAP" - documentation = "Derived from heat_expense_included_in_rent and the canonical heating_type input. Fuel oil, kerosene and propane take the oil row; wood, coal, other and solar heating have no DC matrix row and take the electricity row, as do UNSPECIFIED and NONE. Setting this directly is deprecated during the vocabulary migration and changes only the matrix row: the expense cap still follows the canonical heating_type." + documentation = "Derived from heat_expense_included_in_rent and the canonical heating_type input. Fuel oil, kerosene and propane take the oil row; wood, coal, other and solar heating have no DC matrix row and take the electricity row, as do UNSPECIFIED and NONE. Setting this directly is deprecated during the vocabulary migration and changes only the matrix row: the expense cap still follows the canonical heating_type, and a cooling-only household (a known heating_type, no heat in rent, no heating cost and a cooling cost) takes the electricity amount whatever this row is." defined_for = StateCode.DC def formula(spm_unit, period, parameters): diff --git a/policyengine_us/variables/gov/states/dc/doee/liheap/dc_liheap_payment.py b/policyengine_us/variables/gov/states/dc/doee/liheap/dc_liheap_payment.py index d032fb8c39f..6ea43d3d436 100644 --- a/policyengine_us/variables/gov/states/dc/doee/liheap/dc_liheap_payment.py +++ b/policyengine_us/variables/gov/states/dc/doee/liheap/dc_liheap_payment.py @@ -8,7 +8,14 @@ class dc_liheap_payment(Variable): unit = USD definition_period = YEAR defined_for = "dc_liheap_eligible" - reference = "https://doee.dc.gov/sites/default/files/dc/sites/doee/service_content/attachments/DOEE%20FY24%20LIHEAP_REGULAR_Benefits_Table-Matrix.pdf" + reference = ( + "https://doee.dc.gov/sites/default/files/dc/sites/doee/service_content/attachments/DOEE%20FY24%20LIHEAP_REGULAR_Benefits_Table-Matrix.pdf", + "https://doee.dc.gov/liheap", + "https://doee.dc.gov/sites/default/files/dc/sites/doee/service_content/attachments/DC%20DOEE%20-%20FY26%20LIHEAP%20Benefit%20Matrix.docx", + "https://doee.dc.gov/sites/default/files/dc/sites/doee/service_content/attachments/LIHEAP%20FAQ_1_0.pdf#page=1", + # PDF pages 4, 11 + "https://liheapch.acf.gov/docs/2026/state-plans/DC_Plan_2026.pdf#page=4", + ) def formula(spm_unit, period, parameters): p = parameters(period).gov.states.dc.doee.liheap.payment @@ -44,9 +51,26 @@ def formula(spm_unit, period, parameters): heating_expenses = where( unspecified, legacy_expense, spm_unit("heating_expense", period) ) + # The one-time Regular benefit helps with heating and cooling costs, with + # no fuel condition. A household with a known heating type, no heat in + # the rent and no heating cost that pays for cooling takes the Electric + # row, capped at its electricity bill, whatever its heating row is. + heat_in_rent = heating_type == types.HEAT_IN_RENT + cooling_only = ( + ~unspecified + & ~heat_in_rent + & (heating_expenses == 0) + & spm_unit("has_cooling_expense", period) + ) + expense_cap = where( + cooling_only, + spm_unit("pre_subsidy_electricity_expense", period), + heating_expenses, + ) # Heat-in-rent is a direct subsidy — no expense cap. - matrix_amount = select( + electricity_amount = p.electricity[housing_type][income_level][capped_size] + heating_amount = select( [ heating_type == types.ELECTRICITY, heating_type == types.GAS, @@ -54,12 +78,12 @@ def formula(spm_unit, period, parameters): heating_type == types.OIL, ], [ - p.electricity[housing_type][income_level][capped_size], + electricity_amount, p.gas[housing_type][income_level][capped_size], p.heat_in_rent, p.oil, ], default=0, ) - heat_in_rent = heating_type == types.HEAT_IN_RENT - return where(heat_in_rent, matrix_amount, min_(matrix_amount, heating_expenses)) + matrix_amount = where(cooling_only, electricity_amount, heating_amount) + return where(heat_in_rent, matrix_amount, min_(matrix_amount, expense_cap))