From 02b2b3186a6329c8ce4b1c7121c75399974a3594 Mon Sep 17 00:00:00 2001 From: Ziming Date: Sat, 5 Sep 2026 23:36:36 -0400 Subject: [PATCH 1/6] Read SNAP utility incurrence from the canonical heating inputs has_heating_cooling_expense derives heating from heating_expense net of heat_expense_included_in_rent and cooling from the new has_cooling_expense input, keeping heating_cooling_expense as the signal for UNSPECIFIED households. A new has_gas_and_fuel_expense treats metered gas and every deliverable or cooking fuel as the one SNAP gas-and-fuel utility for both the individual standard and the utility count. utility_expense sums the per-fuel bills without double counting a known heating fuel, and Illinois AABD reads gas_expense for its metered gas allowance, deprecating metered_gas_expense. Co-Authored-By: Claude Fable 5.1 --- ...heating-snap-utility-incurrence.changed.md | 1 + .../aabd/payment/utility/utility_types.yaml | 2 +- .../payment/il_aabd_utility_allowance.yaml | 20 ++++ .../snap_individual_utility_allowance.yaml | 36 +++++++- .../snap_utility_allowance_type.yaml | 59 ++++++++++++ .../count_distinct_utility_expenses.yaml | 36 ++++++++ .../utilities/has_gas_and_fuel_expense.yaml | 42 +++++++++ .../has_heating_cooling_expense.yaml | 91 +++++++++++++++++++ .../expense/utilities/utility_expense.yaml | 52 +++++++++++ .../edge_cases/state/il/aabd.yaml | 2 +- .../payment/utility/il_utility_allowance.py | 11 ++- .../snap_individual_utility_allowance.py | 9 +- .../count_distinct_utility_expenses.py | 24 ++--- .../expense/utilities/has_cooling_expense.py | 12 +++ .../utilities/has_gas_and_fuel_expense.py | 30 ++++++ .../utilities/has_heating_cooling_expense.py | 18 +++- .../expense/utilities/metered_gas_expense.py | 1 + .../expense/utilities/utility_expense.py | 33 +++++-- 18 files changed, 446 insertions(+), 33 deletions(-) create mode 100644 changelog.d/heating-snap-utility-incurrence.changed.md create mode 100644 policyengine_us/tests/policy/baseline/household/expense/utilities/has_gas_and_fuel_expense.yaml create mode 100644 policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_cooling_expense.yaml create mode 100644 policyengine_us/tests/policy/baseline/household/expense/utilities/utility_expense.yaml create mode 100644 policyengine_us/variables/household/expense/utilities/has_cooling_expense.py create mode 100644 policyengine_us/variables/household/expense/utilities/has_gas_and_fuel_expense.py diff --git a/changelog.d/heating-snap-utility-incurrence.changed.md b/changelog.d/heating-snap-utility-incurrence.changed.md new file mode 100644 index 00000000000..35e411e0742 --- /dev/null +++ b/changelog.d/heating-snap-utility-incurrence.changed.md @@ -0,0 +1 @@ +SNAP utility allowance incurrence reads the canonical heating inputs: `has_heating_cooling_expense` derives heating from `heating_expense` net of `heat_expense_included_in_rent` and cooling from the new `has_cooling_expense` input (keeping `heating_cooling_expense` as the signal for `UNSPECIFIED` households), the gas-and-fuel individual standard and the utility count treat metered gas and every deliverable or cooking fuel as one incurred utility via `has_gas_and_fuel_expense`, `utility_expense` sums the per-fuel bills without double counting a known heating fuel, and Illinois AABD reads `gas_expense` for its metered gas allowance, deprecating `metered_gas_expense`. diff --git a/policyengine_us/parameters/gov/states/il/dhs/aabd/payment/utility/utility_types.yaml b/policyengine_us/parameters/gov/states/il/dhs/aabd/payment/utility/utility_types.yaml index de09a0883f3..608afa4f97a 100644 --- a/policyengine_us/parameters/gov/states/il/dhs/aabd/payment/utility/utility_types.yaml +++ b/policyengine_us/parameters/gov/states/il/dhs/aabd/payment/utility/utility_types.yaml @@ -6,7 +6,7 @@ values: - cooking_fuel_expense - coal_expense - fuel_oil_expense - - metered_gas_expense + - gas_expense - bottled_gas_expense metadata: diff --git a/policyengine_us/tests/policy/baseline/gov/states/il/dhs/aabd/payment/il_aabd_utility_allowance.yaml b/policyengine_us/tests/policy/baseline/gov/states/il/dhs/aabd/payment/il_aabd_utility_allowance.yaml index a8bd47025f6..5207f9c4a4d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/il/dhs/aabd/payment/il_aabd_utility_allowance.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/il/dhs/aabd/payment/il_aabd_utility_allowance.yaml @@ -49,3 +49,23 @@ state_code: IL output: il_aabd_utility_allowance: 0 + +- name: Case 6, size 1, area 1, gas expense higher than the metered gas allowance. + period: 2022-01 + input: + spm_unit_size: 1 + county_str: COOK_COUNTY_IL + gas_expense: 20 * 12 + state_code: IL + output: + il_aabd_utility_allowance: 11.9 + +- name: Case 7, the deprecated metered_gas_expense input is no longer read. + period: 2022-01 + input: + spm_unit_size: 1 + county_str: COOK_COUNTY_IL + metered_gas_expense: 20 * 12 + state_code: IL + output: + il_aabd_utility_allowance: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_individual_utility_allowance.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_individual_utility_allowance.yaml index 57d39a17781..75b2d1b4771 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_individual_utility_allowance.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_individual_utility_allowance.yaml @@ -83,8 +83,8 @@ snap_utility_region_str: MO gas_expense: 2_400 output: - # MO FY26 gas standard, read via the gas_and_fuel_expense -> gas_expense - # variable mapping + # MO FY26 gas and fuel standard, read via the gas_and_fuel_expense -> + # has_gas_and_fuel_expense incurrence mapping snap_individual_utility_allowance: 162 # HI individual utility allowances vary by household size @@ -246,3 +246,35 @@ phone_expense: 240 output: snap_individual_utility_allowance: 41 + +# The gas and fuel standard is one individual standard covering metered gas +# and every deliverable or cooking fuel (7 CFR 273.9(d)(6)(iii)(A)(1)). +- name: MO fuel-oil-only household receives the gas and fuel standard in 2026 + period: 2026-01 + input: + snap_utility_allowance_type: IUA + snap_utility_region_str: MO + fuel_oil_expense: 1_800 + output: + # MO FY26 gas and fuel standard + snap_individual_utility_allowance: 162 + +- name: MO metered gas plus propane household receives the gas and fuel standard once in 2026 + period: 2026-01 + input: + snap_utility_allowance_type: IUA + snap_utility_region_str: MO + gas_expense: 2_400 + bottled_gas_expense: 600 + output: + snap_individual_utility_allowance: 162 + +- name: MO wood and cooking fuel household receives the gas and fuel standard once in 2026 + period: 2026-01 + input: + snap_utility_allowance_type: IUA + snap_utility_region_str: MO + wood_expense: 500 + cooking_fuel_expense: 120 + output: + snap_individual_utility_allowance: 162 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_type.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_type.yaml index a4e7ec65208..afb927fa34a 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_type.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_type.yaml @@ -213,3 +213,62 @@ has_heating_cooling_expense: false output: snap_utility_allowance_type: LUA + +# End-to-end cases through has_heating_cooling_expense and +# count_distinct_utility_expenses from the canonical heating inputs +# (7 CFR 273.9(d)(6)(iii)(D)). Colorado has an LUA from October 2021. +- name: Fuel oil heat with a fuel oil bill receives the SUA + period: 2026 + input: + heating_type: FUEL_OIL + fuel_oil_expense: 1_500 + snap_utility_region_str: CO + snap_state_using_standard_utility_allowance: false + output: + snap_utility_allowance_type: SUA + +- name: Gas heat included in rent with gas and electricity bills receives the LUA + period: 2026 + input: + heating_type: NATURAL_GAS + gas_expense: 1_200 + pre_subsidy_electricity_expense: 900 + heat_expense_included_in_rent: true + snap_utility_region_str: CO + snap_state_using_standard_utility_allowance: false + output: + # Heat in rent is not a separately incurred heating cost; the gas and + # electricity bills are two non-heating utilities. + snap_utility_allowance_type: LUA + +- name: A home without heating that pays for cooling receives the SUA + period: 2026 + input: + heating_type: NONE + has_cooling_expense: true + snap_utility_region_str: CO + snap_state_using_standard_utility_allowance: false + output: + snap_utility_allowance_type: SUA + +- name: Legacy heating_cooling_expense with heat in rent still receives the SUA + period: 2026 + input: + heating_cooling_expense: 600 + heat_expense_included_in_rent: true + snap_utility_region_str: CO + snap_state_using_standard_utility_allowance: false + output: + # Pre-canonical households never read the heat-in-rent flag here. + snap_utility_allowance_type: SUA + +- name: Gas and electricity bills without a heating type receive the LUA, not the SUA + period: 2026 + input: + gas_expense: 1_200 + pre_subsidy_electricity_expense: 900 + snap_utility_region_str: CO + snap_state_using_standard_utility_allowance: false + output: + # Per-fuel bills alone do not say which fuel heats the home. + snap_utility_allowance_type: LUA diff --git a/policyengine_us/tests/policy/baseline/household/expense/utilities/count_distinct_utility_expenses.yaml b/policyengine_us/tests/policy/baseline/household/expense/utilities/count_distinct_utility_expenses.yaml index 378a244ec89..bd46a4a486e 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/utilities/count_distinct_utility_expenses.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/utilities/count_distinct_utility_expenses.yaml @@ -23,3 +23,39 @@ sewage_expense: 0 output: count_distinct_utility_expenses: 0 + +# Gas and fuel bills are one SNAP individual-standard category +# (7 CFR 273.9(d)(6)(iii)(A)(1)), so metered gas plus a deliverable fuel +# counts once, while a deliverable fuel with no metered gas still counts. +- name: Fuel oil and electricity count as two utilities. + period: 2026 + input: + fuel_oil_expense: 1_500 + pre_subsidy_electricity_expense: 1_200 + output: + count_distinct_utility_expenses: 2 + +- name: Metered gas plus propane count as one gas and fuel utility. + period: 2026 + input: + gas_expense: 600 + bottled_gas_expense: 300 + output: + count_distinct_utility_expenses: 1 + +- name: Cooking fuel counts as the gas and fuel utility. + period: 2026 + input: + cooking_fuel_expense: 108 + output: + count_distinct_utility_expenses: 1 + +- name: Wood, coal, and other fuel bills with water count as two utilities. + period: 2026 + input: + wood_expense: 400 + coal_expense: 100 + other_heating_fuel_expense: 50 + water_expense: 240 + output: + count_distinct_utility_expenses: 2 diff --git a/policyengine_us/tests/policy/baseline/household/expense/utilities/has_gas_and_fuel_expense.yaml b/policyengine_us/tests/policy/baseline/household/expense/utilities/has_gas_and_fuel_expense.yaml new file mode 100644 index 00000000000..20a73d05f85 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/utilities/has_gas_and_fuel_expense.yaml @@ -0,0 +1,42 @@ +- name: Case 1, metered gas bill. + period: 2026 + input: + gas_expense: 600 + output: + has_gas_and_fuel_expense: true + +- name: Case 2, propane bill. + period: 2026 + input: + bottled_gas_expense: 300 + output: + has_gas_and_fuel_expense: true + +- name: Case 3, cooking fuel bill. + period: 2026 + input: + cooking_fuel_expense: 108 + output: + has_gas_and_fuel_expense: true + +- name: Case 4, other heating fuel bill. + period: 2026 + input: + other_heating_fuel_expense: 50 + output: + has_gas_and_fuel_expense: true + +- name: Case 5, electricity alone is not a gas or fuel bill. + period: 2026 + input: + pre_subsidy_electricity_expense: 1_200 + gas_expense: 0 + output: + has_gas_and_fuel_expense: false + +- name: Case 6, no bills. + period: 2026 + input: + gas_expense: 0 + output: + has_gas_and_fuel_expense: false diff --git a/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_cooling_expense.yaml b/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_cooling_expense.yaml new file mode 100644 index 00000000000..b74f78c4375 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_cooling_expense.yaml @@ -0,0 +1,91 @@ +# 7 CFR 273.9(d)(6)(iii)(D)(1): the heating and cooling standard goes to +# households that incur heating or cooling expenses separately from rent. +- name: Case 1, legacy heating_cooling_expense signals incurrence for an UNSPECIFIED heating type. + period: 2026 + input: + heating_cooling_expense: 600 + output: + has_heating_cooling_expense: true + +- name: Case 2, no heating or cooling inputs at all. + period: 2026 + input: + heating_cooling_expense: 0 + output: + has_heating_cooling_expense: false + +- name: Case 3, natural gas heat with a gas bill incurs heating. + period: 2026 + input: + heating_type: NATURAL_GAS + gas_expense: 1_200 + output: + has_heating_cooling_expense: true + +- name: Case 4, natural gas heat included in rent does not incur heating even with a gas bill. + period: 2026 + input: + heating_type: NATURAL_GAS + gas_expense: 1_200 + heat_expense_included_in_rent: true + output: + has_heating_cooling_expense: false + +- name: Case 5, a home without heating that pays for cooling incurs cooling. + period: 2026 + input: + heating_type: NONE + has_cooling_expense: true + output: + has_heating_cooling_expense: true + +- name: Case 6, electric heat in rent with an electricity bill and no cooling incurs neither. + period: 2026 + input: + heating_type: ELECTRICITY + pre_subsidy_electricity_expense: 900 + heat_expense_included_in_rent: true + output: + has_heating_cooling_expense: false + +- name: Case 7, a per-fuel bill alone does not signal heating when the heating type is unspecified. + period: 2026 + input: + gas_expense: 1_200 + output: + has_heating_cooling_expense: false + +- name: Case 8, the legacy adapter ignores heat_expense_included_in_rent as before. + period: 2026 + input: + heating_cooling_expense: 600 + heat_expense_included_in_rent: true + output: + has_heating_cooling_expense: true + +- name: Case 9, a known heating type never reads the legacy input. + period: 2026 + input: + heating_type: NATURAL_GAS + gas_expense: 0 + heating_cooling_expense: 600 + output: + has_heating_cooling_expense: false + +- name: Case 10, fuel oil heat with a fuel oil bill incurs heating. + period: 2026 + input: + heating_type: FUEL_OIL + fuel_oil_expense: 1_500 + output: + has_heating_cooling_expense: true + +- name: Case 11, cooling alongside a known heating type in rent still qualifies. + period: 2026 + input: + heating_type: NATURAL_GAS + gas_expense: 1_200 + heat_expense_included_in_rent: true + has_cooling_expense: true + output: + has_heating_cooling_expense: true diff --git a/policyengine_us/tests/policy/baseline/household/expense/utilities/utility_expense.yaml b/policyengine_us/tests/policy/baseline/household/expense/utilities/utility_expense.yaml new file mode 100644 index 00000000000..92e48ba6636 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/utilities/utility_expense.yaml @@ -0,0 +1,52 @@ +# Texas households so that electricity_expense carries no California +# subsidy netting. +- name: Case 1, legacy household adds heating_cooling_expense to the bills. + period: 2026 + absolute_error_margin: 0.1 + input: + state_code: TX + heating_cooling_expense: 600 + pre_subsidy_electricity_expense: 1_200 + gas_expense: 300 + water_expense: 240 + output: + utility_expense: 2_340 + +- name: Case 2, a known heating type ignores heating_cooling_expense so the gas heat is not counted twice. + period: 2026 + absolute_error_margin: 0.1 + input: + state_code: TX + heating_type: NATURAL_GAS + heating_cooling_expense: 600 + pre_subsidy_electricity_expense: 1_200 + gas_expense: 300 + output: + utility_expense: 1_500 + +- name: Case 3, a home without heating still sums its fuel and phone bills. + period: 2026 + absolute_error_margin: 0.1 + input: + state_code: TX + heating_type: NONE + fuel_oil_expense: 500 + phone_expense: 360 + output: + utility_expense: 860 + +- name: Case 4, every per-fuel column is summed. + period: 2026 + absolute_error_margin: 0.1 + input: + state_code: TX + heating_type: WOOD + wood_expense: 400 + coal_expense: 100 + other_heating_fuel_expense: 50 + bottled_gas_expense: 200 + cooking_fuel_expense: 60 + sewage_expense: 120 + trash_expense: 90 + output: + utility_expense: 1_020 diff --git a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/state/il/aabd.yaml b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/state/il/aabd.yaml index e15d46c71e9..0bcc5f7d541 100644 --- a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/state/il/aabd.yaml +++ b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/state/il/aabd.yaml @@ -68,7 +68,7 @@ spm_unit: members: [head] electricity_expense: 1_200 - metered_gas_expense: 900 + gas_expense: 900 water_expense: 480 tax_units: tax_unit: diff --git a/policyengine_us/variables/gov/states/il/dhs/aabd/payment/utility/il_utility_allowance.py b/policyengine_us/variables/gov/states/il/dhs/aabd/payment/utility/il_utility_allowance.py index fb72f63a218..73f689aca10 100644 --- a/policyengine_us/variables/gov/states/il/dhs/aabd/payment/utility/il_utility_allowance.py +++ b/policyengine_us/variables/gov/states/il/dhs/aabd/payment/utility/il_utility_allowance.py @@ -1,5 +1,10 @@ from policyengine_us.model_api import * +# Parameter keys that differ from the _expense naming of the +# utility_types entries: the regulation's "metered gas" allowance is paid +# against the household's gas_expense. +PARAMETER_KEY_OVERRIDES = {"gas_expense": "metered_gas"} + class il_aabd_utility_allowance(Variable): value_type = float @@ -21,13 +26,11 @@ def formula(spm_unit, period, parameters): allowances = [] for expense in p.utility.utility_types: expense_amount = spm_unit(expense, period) + key = PARAMETER_KEY_OVERRIDES.get(expense, expense.replace("_expense", "")) allowances.append( where( expense_amount > 0, - min_( - expense_amount, - p.utility[expense.replace("_expense", "")][area][capped_size], - ), + min_(expense_amount, p.utility[key][area][capped_size]), 0, ) ) diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_individual_utility_allowance.py b/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_individual_utility_allowance.py index 87f19732a3a..f00dce5520f 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_individual_utility_allowance.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_individual_utility_allowance.py @@ -1,12 +1,13 @@ from policyengine_us.model_api import * -# Expense input variables that differ from the _expense naming of the +# Incurrence variables that differ from the _expense naming of the # utility_types parameter entries. Electricity uses pre-subsidy expenses to # avoid circular references since electricity subsidies depend on SNAP -# enrollment. +# enrollment; gas and fuel is one standard covering metered gas and every +# deliverable or cooking fuel bill, so it reads the combined boolean. EXPENSE_VARIABLE_OVERRIDES = { "electricity_expense": "pre_subsidy_electricity_expense", - "gas_and_fuel_expense": "gas_expense", + "gas_and_fuel_expense": "has_gas_and_fuel_expense", } @@ -43,6 +44,8 @@ def formula(spm_unit, period, parameters): for expense in expense_types: util_name = expense.replace("_expense", "") expense_variable = EXPENSE_VARIABLE_OVERRIDES.get(expense, expense) + # The override may be a boolean incurrence variable; > 0 reads + # both a dollar amount and a boolean as "incurs the expense". incurs_expense = spm_unit(expense_variable, period) > 0 flat_val = utility.single[util_name][region] if util_name in hh_size_utilities: diff --git a/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py b/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py index 4903366ec34..d0214b29afe 100644 --- a/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py +++ b/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py @@ -5,22 +5,22 @@ class count_distinct_utility_expenses(Variable): value_type = int entity = SPMUnit label = "Number of distinct utility expenses" - documentation = "The number of distinct utility expenses." + documentation = "The number of distinct utility expenses the household pays, counted by the SNAP individual-standard categories: electricity, gas and fuel (one category for metered gas and every deliverable or cooking fuel), telephone, trash, water, and sewage. The deprecated heating_cooling_expense input still counts as its own category for unmigrated households." definition_period = YEAR + reference = ( + "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(A)" + ) def formula(spm_unit, period, parameters): - UTILITIES = [ - "heating_cooling", + has_expense = [ + spm_unit("heating_cooling_expense", period) > 0, # Use pre-subsidy expenses to avoid circular references # since electricity subsidies depend on SNAP enrollment. - "pre_subsidy_electricity", - "gas", - "phone", - "trash", - "water", - "sewage", - ] - has_expense = [ - spm_unit(variable + "_expense", period) > 0 for variable in UTILITIES + spm_unit("pre_subsidy_electricity_expense", period) > 0, + spm_unit("has_gas_and_fuel_expense", period), + spm_unit("has_phone_expense", period), + spm_unit("trash_expense", period) > 0, + spm_unit("water_expense", period) > 0, + spm_unit("sewage_expense", period) > 0, ] return sum(has_expense) diff --git a/policyengine_us/variables/household/expense/utilities/has_cooling_expense.py b/policyengine_us/variables/household/expense/utilities/has_cooling_expense.py new file mode 100644 index 00000000000..ea370df11b9 --- /dev/null +++ b/policyengine_us/variables/household/expense/utilities/has_cooling_expense.py @@ -0,0 +1,12 @@ +from policyengine_us.model_api import * + + +class has_cooling_expense(Variable): + value_type = bool + entity = SPMUnit + label = "Has cooling costs" + documentation = "Whether the household incurs air-conditioning or other cooling costs separately from its rent or mortgage. Cooling is not derivable from the electricity bill, so it is its own fact; it qualifies the household for the SNAP heating and cooling standard utility allowance." + definition_period = YEAR + reference = ( + "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(D)(1)" + ) diff --git a/policyengine_us/variables/household/expense/utilities/has_gas_and_fuel_expense.py b/policyengine_us/variables/household/expense/utilities/has_gas_and_fuel_expense.py new file mode 100644 index 00000000000..6e871ad92a9 --- /dev/null +++ b/policyengine_us/variables/household/expense/utilities/has_gas_and_fuel_expense.py @@ -0,0 +1,30 @@ +from policyengine_us.model_api import * + + +class has_gas_and_fuel_expense(Variable): + value_type = bool + entity = SPMUnit + label = "Has gas or fuel costs" + documentation = "Whether the household pays any gas or fuel bill: metered natural gas, propane, fuel oil, kerosene, wood, coal, other heating fuel, or cooking fuel. SNAP publishes one individual standard for gas and fuel together, so these bills count as a single utility." + definition_period = YEAR + reference = ( + "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(A)(1)" + ) + + def formula(spm_unit, period, parameters): + return ( + add( + spm_unit, + period, + [ + "gas_expense", + "bottled_gas_expense", + "fuel_oil_expense", + "wood_expense", + "coal_expense", + "other_heating_fuel_expense", + "cooking_fuel_expense", + ], + ) + > 0 + ) diff --git a/policyengine_us/variables/household/expense/utilities/has_heating_cooling_expense.py b/policyengine_us/variables/household/expense/utilities/has_heating_cooling_expense.py index d81662b42d5..07eccefdd62 100644 --- a/policyengine_us/variables/household/expense/utilities/has_heating_cooling_expense.py +++ b/policyengine_us/variables/household/expense/utilities/has_heating_cooling_expense.py @@ -5,7 +5,23 @@ class has_heating_cooling_expense(Variable): value_type = bool entity = SPMUnit label = "Has heating/cooling costs" + documentation = "Whether the household incurs heating or cooling costs separately from its rent or mortgage, which qualifies it for the SNAP heating and cooling standard utility allowance. Heating is incurred when the primary heating fuel's bill is positive and heat is not included in rent; cooling is the has_cooling_expense input. Households whose heating_type is UNSPECIFIED keep the pre-canonical signal, a positive heating_cooling_expense." definition_period = YEAR + reference = ( + "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(D)" + ) def formula(spm_unit, period, parameters): - return add(spm_unit, period, ["heating_cooling_expense"]) > 0 + heating_type = spm_unit("heating_type", period) + unspecified = heating_type == heating_type.possible_values.UNSPECIFIED + heating_expense = spm_unit("heating_expense", period) + heat_in_rent = spm_unit("heat_expense_included_in_rent", period) + incurs_heating = (heating_expense > 0) & ~heat_in_rent + incurs_cooling = spm_unit("has_cooling_expense", period) + # Deprecated legacy adapter: heating_cooling_expense was the only + # heating/cooling signal before heating_type existed. It never read + # heat_expense_included_in_rent, and that behavior is preserved for + # unmigrated households. + legacy_expense = spm_unit("heating_cooling_expense", period) + legacy_incurs = unspecified & (legacy_expense > 0) + return incurs_heating | incurs_cooling | legacy_incurs diff --git a/policyengine_us/variables/household/expense/utilities/metered_gas_expense.py b/policyengine_us/variables/household/expense/utilities/metered_gas_expense.py index 9e7113f1288..4e308e99975 100644 --- a/policyengine_us/variables/household/expense/utilities/metered_gas_expense.py +++ b/policyengine_us/variables/household/expense/utilities/metered_gas_expense.py @@ -7,3 +7,4 @@ class metered_gas_expense(Variable): label = "Metered gas expense" unit = USD definition_period = YEAR + documentation = "Deprecated: a duplicate of gas_expense, which every program now reads (the Illinois AABD metered gas allowance included). Set gas_expense instead; this input is no longer read anywhere." diff --git a/policyengine_us/variables/household/expense/utilities/utility_expense.py b/policyengine_us/variables/household/expense/utilities/utility_expense.py index 10335a40ce6..a86ed5b132a 100644 --- a/policyengine_us/variables/household/expense/utilities/utility_expense.py +++ b/policyengine_us/variables/household/expense/utilities/utility_expense.py @@ -6,14 +6,29 @@ class utility_expense(Variable): entity = SPMUnit label = "Utility expenses" unit = USD + documentation = "Total utility bills the household pays separately from rent: electricity, every gas and fuel bill, water, sewage, trash, and telephone. Households whose heating_type is UNSPECIFIED also add the deprecated heating_cooling_expense input, as before the canonical heating inputs existed; a known heating type reads only the per-fuel bills, so the heating cost is never counted twice." definition_period = YEAR - adds = [ - "heating_cooling_expense", - "gas_expense", - "electricity_expense", - "trash_expense", - "water_expense", - "sewage_expense", - "phone_expense", - ] + def formula(spm_unit, period, parameters): + heating_type = spm_unit("heating_type", period) + unspecified = heating_type == heating_type.possible_values.UNSPECIFIED + bills = add( + spm_unit, + period, + [ + "electricity_expense", + "gas_expense", + "bottled_gas_expense", + "fuel_oil_expense", + "wood_expense", + "coal_expense", + "other_heating_fuel_expense", + "cooking_fuel_expense", + "water_expense", + "sewage_expense", + "trash_expense", + "phone_expense", + ], + ) + legacy_expense = spm_unit("heating_cooling_expense", period) + return bills + where(unspecified, legacy_expense, 0) From 50f940eebf815b747a999a42f3c68d0939a9fe44 Mon Sep 17 00:00:00 2001 From: Ziming Date: Sat, 12 Sep 2026 12:07:05 -0400 Subject: [PATCH 2/6] Address review: explicit heating fact and gated legacy utility count Add has_heating_expense as the SNAP heating-responsibility fact, defaulting to the primary fuel's bill being positive with heat not included in rent and settable directly when a secondary fuel heats the home; a fuel bill cannot show what the fuel is used for, so no fuel name implies heating. has_heating_cooling_expense combines it with has_cooling_expense and the UNSPECIFIED-gated legacy signal. count_distinct_utility_expenses reads heating_cooling_expense only for UNSPECIFIED households, matching the other adapters. Co-Authored-By: Claude Fable 5.1 --- ...heating-snap-utility-incurrence.changed.md | 2 +- .../snap_utility_allowance_type.yaml | 24 +++++++++ .../count_distinct_utility_expenses.yaml | 19 +++++++ .../has_heating_cooling_expense.yaml | 39 +++++++++++++++ .../utilities/has_heating_expense.yaml | 50 +++++++++++++++++++ .../count_distinct_utility_expenses.py | 8 ++- .../utilities/has_heating_cooling_expense.py | 6 +-- .../expense/utilities/has_heating_expense.py | 19 +++++++ 8 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_expense.yaml create mode 100644 policyengine_us/variables/household/expense/utilities/has_heating_expense.py diff --git a/changelog.d/heating-snap-utility-incurrence.changed.md b/changelog.d/heating-snap-utility-incurrence.changed.md index 35e411e0742..705f8f5f4fa 100644 --- a/changelog.d/heating-snap-utility-incurrence.changed.md +++ b/changelog.d/heating-snap-utility-incurrence.changed.md @@ -1 +1 @@ -SNAP utility allowance incurrence reads the canonical heating inputs: `has_heating_cooling_expense` derives heating from `heating_expense` net of `heat_expense_included_in_rent` and cooling from the new `has_cooling_expense` input (keeping `heating_cooling_expense` as the signal for `UNSPECIFIED` households), the gas-and-fuel individual standard and the utility count treat metered gas and every deliverable or cooking fuel as one incurred utility via `has_gas_and_fuel_expense`, `utility_expense` sums the per-fuel bills without double counting a known heating fuel, and Illinois AABD reads `gas_expense` for its metered gas allowance, deprecating `metered_gas_expense`. +SNAP utility allowance incurrence reads the canonical heating inputs: `has_heating_cooling_expense` combines a new `has_heating_expense` fact (defaulting to the primary fuel's `heating_expense` being positive with heat not included in rent, and settable directly when a secondary fuel heats the home) with the new `has_cooling_expense` input (keeping `heating_cooling_expense` as the signal for `UNSPECIFIED` households only, in the utility count as well), the gas-and-fuel individual standard and the utility count treat metered gas and every deliverable or cooking fuel as one incurred utility via `has_gas_and_fuel_expense`, `utility_expense` sums the per-fuel bills without double counting a known heating fuel, and Illinois AABD reads `gas_expense` for its metered gas allowance, deprecating `metered_gas_expense`. diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_type.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_type.yaml index afb927fa34a..d479f63b5a9 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_type.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_type.yaml @@ -272,3 +272,27 @@ output: # Per-fuel bills alone do not say which fuel heats the home. snap_utility_allowance_type: LUA + +- name: Free wood heat with a purchased propane backup receives the SUA when the household states it pays for heat + period: 2026 + input: + heating_type: WOOD + wood_expense: 0 + bottled_gas_expense: 300 + has_heating_expense: true + snap_utility_region_str: CO + snap_state_using_standard_utility_allowance: false + output: + snap_utility_allowance_type: SUA + +- name: A home without heating does not reach the LUA through a leftover legacy heating amount + period: 2026 + input: + heating_type: NONE + heating_cooling_expense: 600 + water_expense: 240 + snap_utility_region_str: CO + snap_state_using_standard_utility_allowance: false + output: + # One utility (water): individual standards, not the LUA. + snap_utility_allowance_type: IUA diff --git a/policyengine_us/tests/policy/baseline/household/expense/utilities/count_distinct_utility_expenses.yaml b/policyengine_us/tests/policy/baseline/household/expense/utilities/count_distinct_utility_expenses.yaml index bd46a4a486e..f6b1d8e3fe0 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/utilities/count_distinct_utility_expenses.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/utilities/count_distinct_utility_expenses.yaml @@ -59,3 +59,22 @@ water_expense: 240 output: count_distinct_utility_expenses: 2 + +# The deprecated heating_cooling_expense input is read only for households +# whose heating_type is UNSPECIFIED. +- name: A known heating type does not count a leftover legacy heating amount. + period: 2026 + input: + heating_type: NONE + heating_cooling_expense: 600 + water_expense: 240 + output: + count_distinct_utility_expenses: 1 + +- name: An unspecified heating type still counts the legacy heating amount. + period: 2026 + input: + heating_cooling_expense: 600 + water_expense: 240 + output: + count_distinct_utility_expenses: 2 diff --git a/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_cooling_expense.yaml b/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_cooling_expense.yaml index b74f78c4375..e98e933249c 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_cooling_expense.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_cooling_expense.yaml @@ -89,3 +89,42 @@ has_cooling_expense: true output: has_heating_cooling_expense: true + +# A fuel bill cannot say what the fuel is used for (fuel oil and propane +# both run water heaters), so heating that is not the primary fuel's bill +# is stated through the has_heating_expense fact rather than inferred. +- name: Case 12, free gathered wood with a purchased propane backup heater is not inferred from the propane bill. + period: 2026 + input: + heating_type: WOOD + wood_expense: 0 + bottled_gas_expense: 300 + output: + has_heating_cooling_expense: false + +- name: Case 13, the same household stating that it pays for heat qualifies. + period: 2026 + input: + heating_type: WOOD + wood_expense: 0 + bottled_gas_expense: 300 + has_heating_expense: true + output: + has_heating_cooling_expense: true + +- name: Case 14, free gathered wood with fuel oil bought only for hot water is not inferred from the oil bill. + period: 2026 + input: + heating_type: WOOD + wood_expense: 0 + fuel_oil_expense: 400 + output: + has_heating_cooling_expense: false + +- name: Case 15, a home without heating never incurs heating by default. + period: 2026 + input: + heating_type: NONE + wood_expense: 200 + output: + has_heating_cooling_expense: false diff --git a/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_expense.yaml b/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_expense.yaml new file mode 100644 index 00000000000..f5f4e4f3333 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_expense.yaml @@ -0,0 +1,50 @@ +# 7 CFR 273.9(d)(6)(iii)(D)(1): heating costs incurred separately from rent. +- name: Case 1, primary fuel bill with heat not in rent. + period: 2026 + input: + heating_type: NATURAL_GAS + gas_expense: 1_200 + output: + has_heating_expense: true + +- name: Case 2, heat included in rent. + period: 2026 + input: + heating_type: NATURAL_GAS + gas_expense: 1_200 + heat_expense_included_in_rent: true + output: + has_heating_expense: false + +- name: Case 3, a known type with no bill for its fuel. + period: 2026 + input: + heating_type: FUEL_OIL + fuel_oil_expense: 0 + output: + has_heating_expense: false + +- name: Case 4, a home without heating. + period: 2026 + input: + heating_type: NONE + gas_expense: 600 + output: + has_heating_expense: false + +- name: Case 5, an unspecified type has no primary fuel bill. + period: 2026 + input: + fuel_oil_expense: 400 + output: + has_heating_expense: false + +- name: Case 6, the fact can be stated directly when the bill does not show it. + period: 2026 + input: + heating_type: WOOD + wood_expense: 0 + fuel_oil_expense: 400 + has_heating_expense: true + output: + has_heating_expense: true diff --git a/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py b/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py index d0214b29afe..c9df41b6619 100644 --- a/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py +++ b/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py @@ -5,15 +5,19 @@ class count_distinct_utility_expenses(Variable): value_type = int entity = SPMUnit label = "Number of distinct utility expenses" - documentation = "The number of distinct utility expenses the household pays, counted by the SNAP individual-standard categories: electricity, gas and fuel (one category for metered gas and every deliverable or cooking fuel), telephone, trash, water, and sewage. The deprecated heating_cooling_expense input still counts as its own category for unmigrated households." + documentation = "The number of distinct utility expenses the household pays, counted by the SNAP individual-standard categories: electricity, gas and fuel (one category for metered gas and every deliverable or cooking fuel), telephone, trash, water, and sewage. Households whose heating_type is UNSPECIFIED also count the deprecated heating_cooling_expense input as its own category; a known heating type never reads it." definition_period = YEAR reference = ( "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(A)" ) def formula(spm_unit, period, parameters): + heating_type = spm_unit("heating_type", period) + unspecified = heating_type == heating_type.possible_values.UNSPECIFIED + # Deprecated legacy adapter, read only for unmigrated households. + legacy_expense = spm_unit("heating_cooling_expense", period) has_expense = [ - spm_unit("heating_cooling_expense", period) > 0, + unspecified & (legacy_expense > 0), # Use pre-subsidy expenses to avoid circular references # since electricity subsidies depend on SNAP enrollment. spm_unit("pre_subsidy_electricity_expense", period) > 0, diff --git a/policyengine_us/variables/household/expense/utilities/has_heating_cooling_expense.py b/policyengine_us/variables/household/expense/utilities/has_heating_cooling_expense.py index 07eccefdd62..c70949c411f 100644 --- a/policyengine_us/variables/household/expense/utilities/has_heating_cooling_expense.py +++ b/policyengine_us/variables/household/expense/utilities/has_heating_cooling_expense.py @@ -5,7 +5,7 @@ class has_heating_cooling_expense(Variable): value_type = bool entity = SPMUnit label = "Has heating/cooling costs" - documentation = "Whether the household incurs heating or cooling costs separately from its rent or mortgage, which qualifies it for the SNAP heating and cooling standard utility allowance. Heating is incurred when the primary heating fuel's bill is positive and heat is not included in rent; cooling is the has_cooling_expense input. Households whose heating_type is UNSPECIFIED keep the pre-canonical signal, a positive heating_cooling_expense." + documentation = "Whether the household incurs heating or cooling costs separately from its rent or mortgage, which qualifies it for the SNAP heating and cooling standard utility allowance. Heating is the has_heating_expense fact and cooling is the has_cooling_expense fact. Households whose heating_type is UNSPECIFIED keep the pre-canonical signal, a positive heating_cooling_expense." definition_period = YEAR reference = ( "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(D)" @@ -14,9 +14,7 @@ class has_heating_cooling_expense(Variable): def formula(spm_unit, period, parameters): heating_type = spm_unit("heating_type", period) unspecified = heating_type == heating_type.possible_values.UNSPECIFIED - heating_expense = spm_unit("heating_expense", period) - heat_in_rent = spm_unit("heat_expense_included_in_rent", period) - incurs_heating = (heating_expense > 0) & ~heat_in_rent + incurs_heating = spm_unit("has_heating_expense", period) incurs_cooling = spm_unit("has_cooling_expense", period) # Deprecated legacy adapter: heating_cooling_expense was the only # heating/cooling signal before heating_type existed. It never read diff --git a/policyengine_us/variables/household/expense/utilities/has_heating_expense.py b/policyengine_us/variables/household/expense/utilities/has_heating_expense.py new file mode 100644 index 00000000000..b09af569718 --- /dev/null +++ b/policyengine_us/variables/household/expense/utilities/has_heating_expense.py @@ -0,0 +1,19 @@ +from policyengine_us.model_api import * + + +class has_heating_expense(Variable): + value_type = bool + entity = SPMUnit + label = "Has heating costs" + documentation = "Whether the household incurs home heating costs separately from its rent or mortgage. By default this is true when the primary heating fuel's bill is positive and heat is not included in rent. A fuel bill cannot say what the fuel is used for, so a household whose heating cost is not the primary fuel's bill (for example, free gathered wood with a purchased propane or fuel oil backup heater) should set this fact directly." + definition_period = YEAR + reference = ( + "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(D)(1)" + ) + + def formula(spm_unit, period, parameters): + # heating_expense is zero for NONE and UNSPECIFIED heating types, so + # a positive bill already implies a known heating fuel. + primary_fuel_bill = spm_unit("heating_expense", period) + heat_in_rent = spm_unit("heat_expense_included_in_rent", period) + return (primary_fuel_bill > 0) & ~heat_in_rent From e8e2b7aa475f11498698a6e4e544bff4ef0b3615 Mon Sep 17 00:00:00 2001 From: Ziming Date: Sat, 12 Sep 2026 12:15:58 -0400 Subject: [PATCH 3/6] Shorten the changelog fragment Co-Authored-By: Claude Fable 5.1 --- changelog.d/heating-snap-utility-incurrence.changed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/heating-snap-utility-incurrence.changed.md b/changelog.d/heating-snap-utility-incurrence.changed.md index 705f8f5f4fa..3508c914d5c 100644 --- a/changelog.d/heating-snap-utility-incurrence.changed.md +++ b/changelog.d/heating-snap-utility-incurrence.changed.md @@ -1 +1 @@ -SNAP utility allowance incurrence reads the canonical heating inputs: `has_heating_cooling_expense` combines a new `has_heating_expense` fact (defaulting to the primary fuel's `heating_expense` being positive with heat not included in rent, and settable directly when a secondary fuel heats the home) with the new `has_cooling_expense` input (keeping `heating_cooling_expense` as the signal for `UNSPECIFIED` households only, in the utility count as well), the gas-and-fuel individual standard and the utility count treat metered gas and every deliverable or cooking fuel as one incurred utility via `has_gas_and_fuel_expense`, `utility_expense` sums the per-fuel bills without double counting a known heating fuel, and Illinois AABD reads `gas_expense` for its metered gas allowance, deprecating `metered_gas_expense`. +SNAP utility allowance incurrence now reads the canonical heating inputs through new `has_heating_expense` and `has_cooling_expense` facts, counts metered gas and every deliverable or cooking fuel as one gas-and-fuel utility, and Illinois AABD reads `gas_expense` instead of the deprecated `metered_gas_expense`; households without a `heating_type` keep their previous `heating_cooling_expense` behavior. From ed8fd93ff9f823805bf1503fdb0966b0eb72fd52 Mon Sep 17 00:00:00 2001 From: Ziming Date: Sat, 12 Sep 2026 13:35:40 -0400 Subject: [PATCH 4/6] Add end-to-end SNAP utility allowance cases and tidy the heating docs Reach a dollar allowance from canonical heating inputs on each incurrence path (Alabama SUA, Idaho LUA with heat in rent, Missouri gas-and-fuel IUA with its shelter deduction) instead of forcing the allowance type, attribute the gas-and-fuel standard to the state-set standard in the FNS table column, note that electric heat reads the pre-subsidy electricity bill, update the heating_cooling_expense deprecation text, and wrap long documentation strings. Co-Authored-By: Claude Fable 5.1 --- .../snap_utility_allowance_fy2026.yaml | 80 +++++++++++++++++++ .../snap_individual_utility_allowance.py | 5 +- .../count_distinct_utility_expenses.py | 9 ++- .../expense/utilities/has_cooling_expense.py | 7 +- .../utilities/has_gas_and_fuel_expense.py | 8 +- .../utilities/has_heating_cooling_expense.py | 9 ++- .../expense/utilities/has_heating_expense.py | 11 ++- .../utilities/heating_cooling_expense.py | 8 +- .../expense/utilities/metered_gas_expense.py | 6 +- .../expense/utilities/utility_expense.py | 9 ++- 10 files changed, 142 insertions(+), 10 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_fy2026.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_fy2026.yaml index c75bef8abca..943f7c84f9a 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_fy2026.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_fy2026.yaml @@ -475,3 +475,83 @@ snap_utility_allowance_type: IUA snap_limited_utility_allowance: 0 snap_utility_allowance: 243 + +# Canonical heating inputs (heating_type plus the per-fuel bill) reaching a +# dollar allowance without forcing snap_utility_allowance_type, one case per +# incurrence path in 7 CFR 273.9(d)(6)(iii). +- name: Alabama gas-heated household with a gas bill receives the FY2026 HCSUA from canonical inputs + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + spm_units: + spm_unit: + members: [person] + heating_type: NATURAL_GAS + gas_expense: 1_200 + heat_expense_included_in_rent: false + households: + household: + members: [person] + state_code: AL + output: + # (D)(1): heating incurred separately from rent. + has_heating_expense: true + snap_utility_allowance_type: SUA + snap_utility_allowance: 637 + +- name: Idaho renter with heat in rent and electricity and trash bills receives the FY2026 LUA from canonical inputs + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + spm_units: + spm_unit: + members: [person] + heating_type: NATURAL_GAS + heat_expense_included_in_rent: true + pre_subsidy_electricity_expense: 960 + trash_expense: 360 + households: + household: + members: [person] + state_code: ID + output: + # Heat in rent is not separately incurred; electricity and trash are + # two non-heating utilities (A)(3). + has_heating_expense: false + count_distinct_utility_expenses: 2 + snap_utility_allowance_type: LUA + snap_utility_allowance: 133 + +- name: Missouri home without heating that buys fuel oil for hot water receives the FY2026 gas and fuel standard and the matching shelter deduction + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + spm_units: + spm_unit: + members: [person] + heating_type: NONE + fuel_oil_expense: 1_800 + snap_net_income_pre_shelter: 200 + households: + household: + members: [person] + state_code: MO + output: + # (A)(1): fuel oil alone incurs the single gas and fuel standard; one + # utility is short of Missouri's two-utility LUA. + has_heating_expense: false + has_gas_and_fuel_expense: true + count_distinct_utility_expenses: 1 + snap_utility_allowance_type: IUA + snap_utility_allowance: 162 + # No housing cost: 162 - 0.5 * 200 = 62, under the $744 cap. + snap_excess_shelter_expense_deduction: 62 diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_individual_utility_allowance.py b/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_individual_utility_allowance.py index f00dce5520f..31a29a5782a 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_individual_utility_allowance.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_individual_utility_allowance.py @@ -3,8 +3,9 @@ # Incurrence variables that differ from the _expense naming of the # utility_types parameter entries. Electricity uses pre-subsidy expenses to # avoid circular references since electricity subsidies depend on SNAP -# enrollment; gas and fuel is one standard covering metered gas and every -# deliverable or cooking fuel bill, so it reads the combined boolean. +# enrollment; the state-set gas and fuel standard (the FNS SUA table's gas +# and fuel column) covers metered gas and every deliverable or cooking fuel +# bill, so it reads the combined boolean. EXPENSE_VARIABLE_OVERRIDES = { "electricity_expense": "pre_subsidy_electricity_expense", "gas_and_fuel_expense": "has_gas_and_fuel_expense", diff --git a/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py b/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py index c9df41b6619..61318a93ec0 100644 --- a/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py +++ b/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py @@ -5,7 +5,14 @@ class count_distinct_utility_expenses(Variable): value_type = int entity = SPMUnit label = "Number of distinct utility expenses" - documentation = "The number of distinct utility expenses the household pays, counted by the SNAP individual-standard categories: electricity, gas and fuel (one category for metered gas and every deliverable or cooking fuel), telephone, trash, water, and sewage. Households whose heating_type is UNSPECIFIED also count the deprecated heating_cooling_expense input as its own category; a known heating type never reads it." + documentation = ( + "The number of distinct utility expenses the household pays, counted by the " + "SNAP individual-standard categories: electricity, gas and fuel (one category " + "for metered gas and every deliverable or cooking fuel), telephone, trash, " + "water, and sewage. Households whose heating_type is UNSPECIFIED also count " + "the deprecated heating_cooling_expense input as its own category; a known " + "heating type never reads it." + ) definition_period = YEAR reference = ( "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(A)" diff --git a/policyengine_us/variables/household/expense/utilities/has_cooling_expense.py b/policyengine_us/variables/household/expense/utilities/has_cooling_expense.py index ea370df11b9..48f1495e27f 100644 --- a/policyengine_us/variables/household/expense/utilities/has_cooling_expense.py +++ b/policyengine_us/variables/household/expense/utilities/has_cooling_expense.py @@ -5,7 +5,12 @@ class has_cooling_expense(Variable): value_type = bool entity = SPMUnit label = "Has cooling costs" - documentation = "Whether the household incurs air-conditioning or other cooling costs separately from its rent or mortgage. Cooling is not derivable from the electricity bill, so it is its own fact; it qualifies the household for the SNAP heating and cooling standard utility allowance." + documentation = ( + "Whether the household incurs air-conditioning or other cooling costs " + "separately from its rent or mortgage. Cooling is not derivable from the " + "electricity bill, so it is its own fact; it qualifies the household for the " + "SNAP heating and cooling standard utility allowance." + ) definition_period = YEAR reference = ( "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(D)(1)" diff --git a/policyengine_us/variables/household/expense/utilities/has_gas_and_fuel_expense.py b/policyengine_us/variables/household/expense/utilities/has_gas_and_fuel_expense.py index 6e871ad92a9..434d45e31b4 100644 --- a/policyengine_us/variables/household/expense/utilities/has_gas_and_fuel_expense.py +++ b/policyengine_us/variables/household/expense/utilities/has_gas_and_fuel_expense.py @@ -5,7 +5,13 @@ class has_gas_and_fuel_expense(Variable): value_type = bool entity = SPMUnit label = "Has gas or fuel costs" - documentation = "Whether the household pays any gas or fuel bill: metered natural gas, propane, fuel oil, kerosene, wood, coal, other heating fuel, or cooking fuel. SNAP publishes one individual standard for gas and fuel together, so these bills count as a single utility." + documentation = ( + "Whether the household pays any gas or fuel bill: metered natural gas, " + "propane, fuel oil, kerosene, wood, coal, other heating fuel, or cooking fuel. " + "States set one individual standard per utility type under 7 CFR " + "273.9(d)(6)(iii)(A)(1), and the FNS SUA table's gas and fuel column, which " + "the model's parameter follows, covers all of these bills as a single utility." + ) definition_period = YEAR reference = ( "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(A)(1)" diff --git a/policyengine_us/variables/household/expense/utilities/has_heating_cooling_expense.py b/policyengine_us/variables/household/expense/utilities/has_heating_cooling_expense.py index c70949c411f..2d93353bdab 100644 --- a/policyengine_us/variables/household/expense/utilities/has_heating_cooling_expense.py +++ b/policyengine_us/variables/household/expense/utilities/has_heating_cooling_expense.py @@ -5,7 +5,14 @@ class has_heating_cooling_expense(Variable): value_type = bool entity = SPMUnit label = "Has heating/cooling costs" - documentation = "Whether the household incurs heating or cooling costs separately from its rent or mortgage, which qualifies it for the SNAP heating and cooling standard utility allowance. Heating is the has_heating_expense fact and cooling is the has_cooling_expense fact. Households whose heating_type is UNSPECIFIED keep the pre-canonical signal, a positive heating_cooling_expense." + documentation = ( + "Whether the household incurs heating or cooling costs separately from its " + "rent or mortgage, which qualifies it for the SNAP heating and cooling " + "standard utility allowance. Heating is the has_heating_expense fact and " + "cooling is the has_cooling_expense fact. Households whose heating_type is " + "UNSPECIFIED keep the pre-canonical signal, a positive " + "heating_cooling_expense." + ) definition_period = YEAR reference = ( "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(D)" diff --git a/policyengine_us/variables/household/expense/utilities/has_heating_expense.py b/policyengine_us/variables/household/expense/utilities/has_heating_expense.py index b09af569718..e9742b2f1f4 100644 --- a/policyengine_us/variables/household/expense/utilities/has_heating_expense.py +++ b/policyengine_us/variables/household/expense/utilities/has_heating_expense.py @@ -5,7 +5,16 @@ class has_heating_expense(Variable): value_type = bool entity = SPMUnit label = "Has heating costs" - documentation = "Whether the household incurs home heating costs separately from its rent or mortgage. By default this is true when the primary heating fuel's bill is positive and heat is not included in rent. A fuel bill cannot say what the fuel is used for, so a household whose heating cost is not the primary fuel's bill (for example, free gathered wood with a purchased propane or fuel oil backup heater) should set this fact directly." + documentation = ( + "Whether the household incurs home heating costs separately from its rent or " + "mortgage. By default this is true when the primary heating fuel's bill is " + "positive and heat is not included in rent. A fuel bill cannot say what the " + "fuel is used for, so a household whose heating cost is not the primary fuel's " + "bill (for example, free gathered wood with a purchased propane or fuel oil " + "backup heater) should set this fact directly. Electric and solar heat read " + "pre_subsidy_electricity_expense, not the subsidy-netted electricity_expense, " + "so electric-heat households must send the pre-subsidy bill." + ) definition_period = YEAR reference = ( "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(D)(1)" diff --git a/policyengine_us/variables/household/expense/utilities/heating_cooling_expense.py b/policyengine_us/variables/household/expense/utilities/heating_cooling_expense.py index 4db9bb886f5..7ce86a34927 100644 --- a/policyengine_us/variables/household/expense/utilities/heating_cooling_expense.py +++ b/policyengine_us/variables/household/expense/utilities/heating_cooling_expense.py @@ -7,4 +7,10 @@ class heating_cooling_expense(Variable): label = "Heating and cooling expense" unit = USD definition_period = YEAR - documentation = "Deprecated for heating amounts: set heating_type and the matching per-fuel expense instead; this input remains only as a fallback inside the IL and MA LIHEAP adapters for households whose heating_type is UNSPECIFIED. Still read directly by the SNAP utility allowance determination pending its own migration." + documentation = ( + "Deprecated for heating amounts: set heating_type and the matching per-fuel " + "expense instead; this input remains only as a fallback inside the IL and MA " + "LIHEAP adapters for households whose heating_type is UNSPECIFIED. The SNAP " + "utility allowance determination reads it only for households whose " + "heating_type is UNSPECIFIED." + ) diff --git a/policyengine_us/variables/household/expense/utilities/metered_gas_expense.py b/policyengine_us/variables/household/expense/utilities/metered_gas_expense.py index 4e308e99975..333b8afddc8 100644 --- a/policyengine_us/variables/household/expense/utilities/metered_gas_expense.py +++ b/policyengine_us/variables/household/expense/utilities/metered_gas_expense.py @@ -7,4 +7,8 @@ class metered_gas_expense(Variable): label = "Metered gas expense" unit = USD definition_period = YEAR - documentation = "Deprecated: a duplicate of gas_expense, which every program now reads (the Illinois AABD metered gas allowance included). Set gas_expense instead; this input is no longer read anywhere." + documentation = ( + "Deprecated: a duplicate of gas_expense, which every program now reads (the " + "Illinois AABD metered gas allowance included). Set gas_expense instead; this " + "input is no longer read anywhere." + ) diff --git a/policyengine_us/variables/household/expense/utilities/utility_expense.py b/policyengine_us/variables/household/expense/utilities/utility_expense.py index a86ed5b132a..3c931ca6014 100644 --- a/policyengine_us/variables/household/expense/utilities/utility_expense.py +++ b/policyengine_us/variables/household/expense/utilities/utility_expense.py @@ -6,7 +6,14 @@ class utility_expense(Variable): entity = SPMUnit label = "Utility expenses" unit = USD - documentation = "Total utility bills the household pays separately from rent: electricity, every gas and fuel bill, water, sewage, trash, and telephone. Households whose heating_type is UNSPECIFIED also add the deprecated heating_cooling_expense input, as before the canonical heating inputs existed; a known heating type reads only the per-fuel bills, so the heating cost is never counted twice." + documentation = ( + "Total utility bills the household pays separately from rent: electricity, " + "every gas and fuel bill, water, sewage, trash, and telephone. Households " + "whose heating_type is UNSPECIFIED also add the deprecated " + "heating_cooling_expense input, as before the canonical heating inputs " + "existed; a known heating type reads only the per-fuel bills, so the heating " + "cost is never counted twice." + ) definition_period = YEAR def formula(spm_unit, period, parameters): From e5cdd364683203d3645a7a6374576aa27b298f9a Mon Sep 17 00:00:00 2001 From: Ziming Date: Sat, 12 Sep 2026 21:21:32 -0400 Subject: [PATCH 5/6] Address the automated review on the SNAP utility incurrence change Pin the electric-heat pre-subsidy input pair, reach the HCSUA amount from the cooling fact alone, confirm metered gas and propane bills both sum into utility_expense, and note where monthly formulas read the yearly incurrence facts and dollar inputs. Co-Authored-By: Claude Fable 5.1 --- .../snap_utility_allowance_fy2026.yaml | 23 +++++++++++++++++++ .../utilities/has_heating_expense.yaml | 18 +++++++++++++++ .../expense/utilities/utility_expense.yaml | 13 +++++++++++ .../payment/utility/il_utility_allowance.py | 2 ++ .../snap_individual_utility_allowance.py | 3 +++ .../shelter/snap_utility_allowance_type.py | 2 ++ 6 files changed, 61 insertions(+) diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_fy2026.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_fy2026.yaml index 943f7c84f9a..0cbd28cc145 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_fy2026.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_fy2026.yaml @@ -555,3 +555,26 @@ snap_utility_allowance: 162 # No housing cost: 162 - 0.5 * 200 = 62, under the $744 cap. snap_excess_shelter_expense_deduction: 62 + +- name: Alabama home without heating that pays for cooling receives the FY2026 HCSUA from the stated cooling fact + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + spm_units: + spm_unit: + members: [person] + heating_type: NONE + has_cooling_expense: true + households: + household: + members: [person] + state_code: AL + output: + # (D)(1): heating or cooling incurred separately from rent. + has_heating_expense: false + has_heating_cooling_expense: true + snap_utility_allowance_type: SUA + snap_utility_allowance: 637 diff --git a/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_expense.yaml b/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_expense.yaml index f5f4e4f3333..59278b641ce 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_expense.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_expense.yaml @@ -48,3 +48,21 @@ has_heating_expense: true output: has_heating_expense: true + +# Electric and solar heat read pre_subsidy_electricity_expense; the +# subsidy-netted electricity_expense would be circular, so it is not read. +- name: Case 7, electric heat with only the subsidy-netted electricity bill incurs no heating. + period: 2026 + input: + heating_type: ELECTRICITY + electricity_expense: 900 + output: + has_heating_expense: false + +- name: Case 8, electric heat with the pre-subsidy electricity bill incurs heating. + period: 2026 + input: + heating_type: ELECTRICITY + pre_subsidy_electricity_expense: 900 + output: + has_heating_expense: true diff --git a/policyengine_us/tests/policy/baseline/household/expense/utilities/utility_expense.yaml b/policyengine_us/tests/policy/baseline/household/expense/utilities/utility_expense.yaml index 92e48ba6636..9ab925cf170 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/utilities/utility_expense.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/utilities/utility_expense.yaml @@ -50,3 +50,16 @@ trash_expense: 90 output: utility_expense: 1_020 + +- name: Case 5, metered gas and propane bills both count toward the total. + period: 2026 + absolute_error_margin: 0.1 + input: + state_code: TX + heating_type: NATURAL_GAS + gas_expense: 1_200 + bottled_gas_expense: 300 + output: + # Unlike the utility count, which treats gas and fuel as one utility, + # the expense total sums every bill. + utility_expense: 1_500 diff --git a/policyengine_us/variables/gov/states/il/dhs/aabd/payment/utility/il_utility_allowance.py b/policyengine_us/variables/gov/states/il/dhs/aabd/payment/utility/il_utility_allowance.py index 73f689aca10..7e12fec8f85 100644 --- a/policyengine_us/variables/gov/states/il/dhs/aabd/payment/utility/il_utility_allowance.py +++ b/policyengine_us/variables/gov/states/il/dhs/aabd/payment/utility/il_utility_allowance.py @@ -25,6 +25,8 @@ def formula(spm_unit, period, parameters): # Households may have more than one applicable utility allowance type allowances = [] for expense in p.utility.utility_types: + # YEAR-defined dollar inputs read at this MONTH period are + # divided by 12 before the monthly cap applies. expense_amount = spm_unit(expense, period) key = PARAMETER_KEY_OVERRIDES.get(expense, expense.replace("_expense", "")) allowances.append( diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_individual_utility_allowance.py b/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_individual_utility_allowance.py index 31a29a5782a..b1de3c0ce7b 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_individual_utility_allowance.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_individual_utility_allowance.py @@ -47,6 +47,9 @@ def formula(spm_unit, period, parameters): expense_variable = EXPENSE_VARIABLE_OVERRIDES.get(expense, expense) # The override may be a boolean incurrence variable; > 0 reads # both a dollar amount and a boolean as "incurs the expense". + # These are YEAR-defined facts read at this MONTH period: a + # dollar amount is divided by 12 and a boolean is carried as is, + # which leaves the > 0 test unchanged either way. incurs_expense = spm_unit(expense_variable, period) > 0 flat_val = utility.single[util_name][region] if util_name in hh_size_utilities: diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_utility_allowance_type.py b/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_utility_allowance_type.py index 7fd74288278..f5d5dab6bd7 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_utility_allowance_type.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_utility_allowance_type.py @@ -21,6 +21,8 @@ class snap_utility_allowance_type(Variable): ) def formula(spm_unit, period, parameters): + # The utility count and incurrence facts are YEAR-defined stocks + # read at this MONTH period, so they are carried as is (no รท12). distinct_utility_bills = spm_unit("count_distinct_utility_expenses", period) lua = parameters(period).gov.usda.snap.income.deductions.utility.limited region = spm_unit.household("snap_utility_region_str", period) From 0f44dee73e2be71af30b08deb63fd72ee4510b4f Mon Sep 17 00:00:00 2001 From: Ziming Date: Mon, 14 Sep 2026 12:45:27 -0400 Subject: [PATCH 6/6] Address second-pass review items on SNAP utility incurrence - Cite 7 CFR 273.9(d)(6)(iii)(A)(1) in count_distinct_utility_expenses, matching has_gas_and_fuel_expense for the individual-standard categories the count uses. - Add a has_heating_cooling_expense case where cooling qualifies on its own while heat is included in rent. - Add a snap_utility_allowance_type case for an unspecified heating type with a legacy heating amount and a water bill, pinning that the legacy amount routes to the SUA before the two-utility LUA count applies. Co-Authored-By: Claude Fable 5.1 --- .../deductions/snap_utility_allowance_type.yaml | 13 +++++++++++++ .../utilities/has_heating_cooling_expense.yaml | 11 +++++++++++ .../utilities/count_distinct_utility_expenses.py | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_type.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_type.yaml index d479f63b5a9..38f11db742c 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_type.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_utility_allowance_type.yaml @@ -296,3 +296,16 @@ output: # One utility (water): individual standards, not the LUA. snap_utility_allowance_type: IUA + +- name: An unspecified heating type with a legacy heating amount and a water bill receives the SUA, not the LUA + period: 2026 + input: + heating_cooling_expense: 600 + water_expense: 240 + snap_utility_region_str: CO + snap_state_using_standard_utility_allowance: false + output: + # The legacy amount signals heating incurrence for pre-canonical + # households, so the SUA applies before the two-utility LUA count + # (legacy plus water) is ever reached. + snap_utility_allowance_type: SUA diff --git a/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_cooling_expense.yaml b/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_cooling_expense.yaml index e98e933249c..7e544b031d9 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_cooling_expense.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/utilities/has_heating_cooling_expense.yaml @@ -128,3 +128,14 @@ wood_expense: 200 output: has_heating_cooling_expense: false + +# Cooling is a separate (D)(1) qualifier; the heat-in-rent flag only +# suppresses heating incurrence. +- name: Case 16, cooling qualifies on its own even when heat is included in rent. + period: 2026 + input: + heating_type: NONE + has_cooling_expense: true + heat_expense_included_in_rent: true + output: + has_heating_cooling_expense: true diff --git a/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py b/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py index 61318a93ec0..6e95ca373ac 100644 --- a/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py +++ b/policyengine_us/variables/household/expense/utilities/count_distinct_utility_expenses.py @@ -15,7 +15,7 @@ class count_distinct_utility_expenses(Variable): ) definition_period = YEAR reference = ( - "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(A)" + "https://www.ecfr.gov/current/title-7/section-273.9#p-273.9(d)(6)(iii)(A)(1)" ) def formula(spm_unit, period, parameters):