From 131df094359614cde2ac0e51fc9665abd9d4af05 Mon Sep 17 00:00:00 2001 From: Ziming Date: Sun, 8 Nov 2026 22:01:42 -0500 Subject: [PATCH 1/5] Pay DC LIHEAP to households whose only energy cost is cooling Co-Authored-By: Claude Opus 5.5 --- changelog.d/dc-liheap-cooling.fixed.md | 1 + policyengine_us/programs.yaml | 1 + .../dc/doee/liheap/dc_liheap_payment.yaml | 109 ++++++++++++++++++ .../dc/doee/liheap/dc_liheap_payment.py | 20 +++- 4 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 changelog.d/dc-liheap-cooling.fixed.md diff --git a/changelog.d/dc-liheap-cooling.fixed.md b/changelog.d/dc-liheap-cooling.fixed.md new file mode 100644 index 00000000000..9b625b577ce --- /dev/null +++ b/changelog.d/dc-liheap-cooling.fixed.md @@ -0,0 +1 @@ +DC LIHEAP Regular benefit now pays households without heating that pay their own electricity bill for cooling, from the Electric row capped at that bill. diff --git a/policyengine_us/programs.yaml b/policyengine_us/programs.yaml index a7cef450f8b..e631a2ba475 100644 --- a/policyengine_us/programs.yaml +++ b/policyengine_us/programs.yaml @@ -1096,6 +1096,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_payment.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/doee/liheap/dc_liheap_payment.yaml index 3eea7f9ce34..f7112ef69f8 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,112 @@ # 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 10 hours is 9_334; full time +# at 40.00 an hour is 83_200. Electricity of 1_200 is 100 a month. +- 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_200 + 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_200) = 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: 9_334 + person2: + age: 38 + spm_units: + spm_unit: + members: [person1, person2] + heating_type: NONE + has_cooling_expense: true + # 80 a month. + pre_subsidy_electricity_expense: 960 + dc_liheap_housing_type: SINGLE_FAMILY + households: + household: + members: [person1, person2] + state_code: DC + output: + # 5_000 < 9_334 <= 10_000 + dc_liheap_income_level: 2 + # SF, level 2, two people: Electric 1_106; min(1_106, 960) = 960 + dc_liheap_payment: 960 + +- 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_200 + 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, 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_200 + 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 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..4957bba89f1 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,12 @@ 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/LIHEAP%20FAQ_1_0.pdf#page=1", + "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,6 +49,17 @@ 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. A + # home without heating that pays for cooling takes the Electric row + # (dc_liheap_heating_type), capped at its electricity bill. + cooling_only = ( + canonical_type == canonical_type.possible_values.NONE + ) & 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( @@ -62,4 +78,4 @@ def formula(spm_unit, period, parameters): default=0, ) heat_in_rent = heating_type == types.HEAT_IN_RENT - return where(heat_in_rent, matrix_amount, min_(matrix_amount, heating_expenses)) + return where(heat_in_rent, matrix_amount, min_(matrix_amount, expense_cap)) From 2fa7ce1988f0f43fae0532f8d73c3ce9d2673fcd Mon Sep 17 00:00:00 2001 From: Ziming Date: Mon, 9 Nov 2026 23:06:12 -0500 Subject: [PATCH 2/5] Use a sourced DC electricity bill in LIHEAP cooling tests Replace the placeholder bill with EIA's 2024 DC residential average ($113.23 a month), move the bill-cap case to income level 1, add an UNSPECIFIED cooling case that keeps the legacy adapter, and cite the FY26 benefit matrix. Co-Authored-By: Claude Opus 5.5 --- .../dc/doee/liheap/dc_liheap_payment.yaml | 56 ++++++++++++++----- .../dc/doee/liheap/dc_liheap_payment.py | 2 + 2 files changed, 45 insertions(+), 13 deletions(-) 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 f7112ef69f8..c7676f74c5e 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 @@ -344,8 +344,10 @@ # 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 10 hours is 9_334; full time -# at 40.00 an hour is 83_200. Electricity of 1_200 is 100 a month. +# 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 @@ -359,7 +361,7 @@ members: [person1] heating_type: NONE has_cooling_expense: true - pre_subsidy_electricity_expense: 1_200 + pre_subsidy_electricity_expense: 1_358.76 dc_liheap_housing_type: SINGLE_FAMILY households: household: @@ -369,7 +371,7 @@ 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_200) = 440 + # 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. @@ -379,7 +381,7 @@ people: person1: age: 40 - employment_income: 9_334 + employment_income: 4_667 person2: age: 38 spm_units: @@ -387,18 +389,17 @@ members: [person1, person2] heating_type: NONE has_cooling_expense: true - # 80 a month. - pre_subsidy_electricity_expense: 960 + pre_subsidy_electricity_expense: 1_358.76 dc_liheap_housing_type: SINGLE_FAMILY households: household: members: [person1, person2] state_code: DC output: - # 5_000 < 9_334 <= 10_000 - dc_liheap_income_level: 2 - # SF, level 2, two people: Electric 1_106; min(1_106, 960) = 960 - dc_liheap_payment: 960 + # 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 @@ -413,7 +414,7 @@ members: [person1] heating_type: NONE has_cooling_expense: false - pre_subsidy_electricity_expense: 1_200 + pre_subsidy_electricity_expense: 1_358.76 dc_liheap_housing_type: SINGLE_FAMILY households: household: @@ -436,7 +437,7 @@ members: [person1] heating_type: NONE has_cooling_expense: true - pre_subsidy_electricity_expense: 1_200 + pre_subsidy_electricity_expense: 1_358.76 dc_liheap_housing_type: SINGLE_FAMILY households: household: @@ -446,3 +447,32 @@ # 83_200 exceeds 60% of the one-person state median income dc_liheap_eligible: false dc_liheap_payment: 0 + +# The cooling route keys on the canonical NONE type only. 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 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 4957bba89f1..18185673a51 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 @@ -11,7 +11,9 @@ class dc_liheap_payment(Variable): 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", ) From 563acb3ee915132577e4e8494d039803e0311b56 Mon Sep 17 00:00:00 2001 From: Ziming Date: Wed, 11 Nov 2026 23:19:26 -0500 Subject: [PATCH 3/5] Use HHS's rounded-down 60% state median income limits for DC LIHEAP Co-Authored-By: Claude Opus 5.5 --- changelog.d/dc-liheap-cooling.fixed.md | 2 +- .../dc/doee/liheap/dc_liheap_eligible.yaml | 21 +++++++++++++++---- .../dc/doee/liheap/dc_liheap_eligible.py | 11 +++++++--- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/changelog.d/dc-liheap-cooling.fixed.md b/changelog.d/dc-liheap-cooling.fixed.md index 9b625b577ce..68c520504bf 100644 --- a/changelog.d/dc-liheap-cooling.fixed.md +++ b/changelog.d/dc-liheap-cooling.fixed.md @@ -1 +1 @@ -DC LIHEAP Regular benefit now pays households without heating that pay their own electricity bill for cooling, from the Electric row capped at that bill. +DC LIHEAP Regular benefit now pays households without heating that pay their own electricity bill for cooling, from the Electric row capped at that 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/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/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 From fe2268881063c418d76af61b1ad97ee14d0314c1 Mon Sep 17 00:00:00 2001 From: Ziming Date: Thu, 12 Nov 2026 12:09:18 -0500 Subject: [PATCH 4/5] Pay DC's cooling-only benefit to heated homes with no heating cost Co-Authored-By: Claude Opus 5.5 --- .../dc/doee/liheap/dc_liheap_payment.yaml | 50 +++++++++++++++++++ .../dc/doee/liheap/dc_liheap_payment.py | 22 +++++--- 2 files changed, 64 insertions(+), 8 deletions(-) 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 c7676f74c5e..7f11bb7b603 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 @@ -424,6 +424,56 @@ # 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: 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: Cooling only, income above the limit pays nothing. period: 2026 absolute_error_margin: 0.01 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 18185673a51..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 @@ -51,12 +51,17 @@ 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. A - # home without heating that pays for cooling takes the Electric row - # (dc_liheap_heating_type), capped at its electricity bill. + # 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 = ( - canonical_type == canonical_type.possible_values.NONE - ) & spm_unit("has_cooling_expense", period) + ~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), @@ -64,7 +69,8 @@ def formula(spm_unit, period, parameters): ) # 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, @@ -72,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 + matrix_amount = where(cooling_only, electricity_amount, heating_amount) return where(heat_in_rent, matrix_amount, min_(matrix_amount, expense_cap)) From d684d7d68f477b034522d241f7e9afff80215770 Mon Sep 17 00:00:00 2001 From: Ziming Date: Thu, 12 Nov 2026 15:15:27 -0500 Subject: [PATCH 5/5] Pin the three terms of DC's cooling route with test cases Add a gas-heated home with no gas bill and a cooling cost (Electric $440, not the Gas row), a heat-in-rent tenant with a cooling cost (flat $200), and an unspecified type on the deprecated GAS row with no gas bill ($0). Reword the comment above the unspecified-fuel case, the changelog fragment and the dc_liheap_heating_type documentation for the widened route. Co-Authored-By: Claude Opus 5.5 --- changelog.d/dc-liheap-cooling.fixed.md | 2 +- .../dc/doee/liheap/dc_liheap_payment.yaml | 88 ++++++++++++++++++- .../dc/doee/liheap/dc_liheap_heating_type.py | 2 +- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/changelog.d/dc-liheap-cooling.fixed.md b/changelog.d/dc-liheap-cooling.fixed.md index 68c520504bf..be539bc04af 100644 --- a/changelog.d/dc-liheap-cooling.fixed.md +++ b/changelog.d/dc-liheap-cooling.fixed.md @@ -1 +1 @@ -DC LIHEAP Regular benefit now pays households without heating that pay their own electricity bill for cooling, from the Electric row capped at that bill. DC LIHEAP income limits now use HHS's 60% state median income amounts, rounded down by household size, as DOEE publishes them. +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/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 7f11bb7b603..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 @@ -449,6 +449,33 @@ # 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 @@ -474,6 +501,33 @@ # 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 @@ -498,10 +552,10 @@ dc_liheap_eligible: false dc_liheap_payment: 0 -# The cooling route keys on the canonical NONE type only. 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. +# 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 @@ -526,3 +580,29 @@ 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_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):