diff --git a/changelog.d/pawhp-household-income.fixed.md b/changelog.d/pawhp-household-income.fixed.md new file mode 100644 index 0000000000..989194e3c2 --- /dev/null +++ b/changelog.d/pawhp-household-income.fixed.md @@ -0,0 +1,3 @@ +- Count the Pension Age Winter Heating Payment (`pawhp`) in `household_benefits`, `hbai_household_net_income` and `hbai_benefits`, as the Winter Fuel Payment is. It was in `gov_spending` only, so Scottish pensioners' winter heating payment was missing from household net income and HBAI income from the 2024 qualifying week. +- Make `gov.contrib.policyengine.disable_simulated_benefits` run. `Simulation` now sets `tax_benefit_system.simulation`, through which structural reforms reach the simulation, and the reform no longer names reported amounts that do not exist (Attendance Allowance, DLA and PIP follow the reported award through their categories). +- Under `disable_simulated_benefits`, count the reported winter heating payment once: as `pawhp` for a household in Scotland in the years PAWHP is paid, and as `winter_fuel_allowance` otherwise. diff --git a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py index 54338e3e92..315604c9c5 100644 --- a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py +++ b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py @@ -1,5 +1,36 @@ from policyengine_core.model_api import * +# Benefits set to the amount the survey reports, _reported, in place +# of the simulated amount. Attendance Allowance, DLA and PIP are not listed: +# they are paid at the rate of the award category the survey reports +# (aa_category, dla_sc_category, dla_m_category, pip_dl_category and +# pip_m_category), so they already follow the reported award. The winter +# heating payments are set separately (see below). +BENEFITS = [ + "afcs", + "bsp", + "carers_allowance", + "child_benefit", + "child_tax_credit", + "council_tax_benefit", + "esa_contrib", + "esa_income", + "housing_benefit", + "iidb", + "incapacity_benefit", + "income_support", + "jsa_contrib", + "jsa_income", + "pension_credit", + "sda", + "ssmg", + "state_pension", + "universal_credit", + "working_tax_credit", +] + +YEARS_IN_FUTURE = 10 + def disable_simulated_benefits(parameters, period): if parameters(period).gov.contrib.policyengine.disable_simulated_benefits: @@ -7,37 +38,9 @@ def disable_simulated_benefits(parameters, period): class DisableSimulatedBenefits(Reform): def apply(self): simulation = self.simulation + time_period = int(simulation.dataset.time_period) + years = range(time_period, time_period + YEARS_IN_FUTURE) - BENEFITS = [ - "afcs", - "attendance_allowance", - "bsp", - "carers_allowance", - "child_benefit", - "child_tax_credit", - "council_tax_benefit", - "dla_m", - "dla_sc", - "esa_contrib", - "esa_income", - "housing_benefit", - "iidb", - "incapacity_benefit", - "income_support", - "jsa_contrib", - "jsa_income", - "pension_credit", - "pip_dl", - "pip_m", - "sda", - "ssmg", - "state_pension", - "universal_credit", - "winter_fuel_allowance", - "working_tax_credit", - ] - time_period = simulation.dataset.time_period - YEARS_IN_FUTURE = 10 for variable in BENEFITS: entity = simulation.tax_benefit_system.variables[ variable @@ -45,14 +48,50 @@ def apply(self): reported_value = simulation.calculate( variable + "_reported", time_period, map_to=entity ) - for year in range(time_period, time_period + YEARS_IN_FUTURE): + for year in years: simulation.set_input(variable, year, reported_value) if variable in ["child_tax_credit", "working_tax_credit"]: # CTC and WTC have their own pre_minimum variables because tax credits aren't paid if # below a threshold. variable = variable + "_pre_minimum" - for year in range(time_period, time_period + YEARS_IN_FUTURE): + for year in years: simulation.set_input(variable, year, reported_value) + # The survey reports one winter heating payment: FRS benefit + # code 62, which policyengine-uk-data loads into + # winter_fuel_allowance_reported for every respondent, + # Scotland included. Scotland's payment has been the Pension + # Age Winter Heating Payment since the 2024 qualifying week, + # so in each year a Scottish household's report becomes its + # pawhp while PAWHP is paid, and its winter_fuel_allowance + # before then. Both variables are household income, so each + # report is counted once, under the scheme that pays it. + reported_winter_heating = np.asarray( + simulation.calculate( + "winter_fuel_allowance_reported", + time_period, + map_to="household", + ) + ) + in_scotland = ( + np.asarray(simulation.calculate("country", time_period)) + == "SCOTLAND" + ) + for year in years: + paid_as_pawhp = ( + in_scotland + & parameters(year).gov.social_security_scotland.pawhp.active + ) + simulation.set_input( + "winter_fuel_allowance", + year, + np.where(paid_as_pawhp, 0, reported_winter_heating), + ) + simulation.set_input( + "pawhp", + year, + np.where(paid_as_pawhp, reported_winter_heating, 0), + ) + return DisableSimulatedBenefits diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 2acef5aefe..ae23cddb95 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -198,6 +198,13 @@ def __init__( self.tax_benefit_system.reset_parameter_caches() + # Core applies a reform by calling its apply() with the tax-benefit + # system as self, so reforms that change the data (such as + # disable_simulated_benefits) reach the simulation through + # tax_benefit_system.simulation. Core's Simulation.__init__ sets it; + # this __init__ replaces core's, so it sets it too. + self.tax_benefit_system.simulation = self + # Apply structural reforms based on parameters structural_reform = create_structural_reforms_from_parameters( self.tax_benefit_system.parameters, diff --git a/policyengine_uk/tests/code_health/test_household_income_lists.py b/policyengine_uk/tests/code_health/test_household_income_lists.py new file mode 100644 index 0000000000..6a419eb8cc --- /dev/null +++ b/policyengine_uk/tests/code_health/test_household_income_lists.py @@ -0,0 +1,63 @@ +"""Government spending on households is household income. + +gov_spending counted the Pension Age Winter Heating Payment (pawhp), but +household_benefits and the HBAI income lists did not: when the Winter Fuel +Payment stopped covering Scotland, Scottish pensioners' winter heating +payment fell out of household net income and HBAI income. These tests tie +the lists together so a payment added to one is not left out of the others. +""" + +from policyengine_uk import CountryTaxBenefitSystem +from policyengine_uk.variables.gov.gov_spending import GOV_SPENDING_VARIABLES +from policyengine_uk.variables.household.income.hbai_household_net_income import ( + HBAI_HOUSEHOLD_NET_INCOME_ADDS, +) +from policyengine_uk.variables.household.income.household_benefits import ( + HOUSEHOLD_BENEFIT_VARIABLES, +) + +SYSTEM = CountryTaxBenefitSystem() + +# Spending in gov_spending that is not a benefit received by the household. +NOT_HOUSEHOLD_BENEFITS = { + "other_public_spending_budget_change": ( + "the contrib lever gov.contrib.policyengine.budget.other_public_spending, " + "spread across households by income decile; zero unless set" + ), +} + +# The winter heating payment a pensioner household receives: the Winter Fuel +# Payment, or in Scotland from the 2024 qualifying week the Pension Age +# Winter Heating Payment. +WINTER_HEATING_PAYMENTS = ["winter_fuel_allowance", "pawhp"] + + +def test_government_spending_on_households_is_household_income(): + outside = set(GOV_SPENDING_VARIABLES) - set(HOUSEHOLD_BENEFIT_VARIABLES) + assert outside == set(NOT_HOUSEHOLD_BENEFITS), ( + "gov_spending counts these but household_benefits does not: " + f"{sorted(outside - set(NOT_HOUSEHOLD_BENEFITS))}; listed as not " + "household benefits but now in household_benefits: " + f"{sorted(set(NOT_HOUSEHOLD_BENEFITS) - outside)}" + ) + + +def test_winter_heating_payments_are_counted_in_every_income_list(): + lists = { + "HOUSEHOLD_BENEFIT_VARIABLES": HOUSEHOLD_BENEFIT_VARIABLES, + "HBAI_HOUSEHOLD_NET_INCOME_ADDS": HBAI_HOUSEHOLD_NET_INCOME_ADDS, + "hbai_benefits.adds": SYSTEM.variables["hbai_benefits"].adds, + "GOV_SPENDING_VARIABLES": GOV_SPENDING_VARIABLES, + } + missing = { + name: [payment for payment in WINTER_HEATING_PAYMENTS if payment not in names] + for name, names in lists.items() + } + assert not any(missing.values()), missing + repeated = { + name: [ + payment for payment in WINTER_HEATING_PAYMENTS if names.count(payment) > 1 + ] + for name, names in lists.items() + } + assert not any(repeated.values()), repeated diff --git a/policyengine_uk/tests/policy/baseline/household/income/winter_heating_payments_in_income.yaml b/policyengine_uk/tests/policy/baseline/household/income/winter_heating_payments_in_income.yaml new file mode 100644 index 0000000000..9e0c83f2ed --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/income/winter_heating_payments_in_income.yaml @@ -0,0 +1,153 @@ +# Winter heating payments in household income. Scotland's Pension Age Winter +# Heating Payment (pawhp) replaced the Winter Fuel Payment +# (winter_fuel_allowance) from the 2024 qualifying week, so it counts in +# household_benefits, household_net_income, hbai_household_net_income and +# hbai_benefits exactly as the Winter Fuel Payment does. +# +# Each household's other income is pinned: a State Pension entered directly, +# Pension Credit entered on the benefit unit, and no council tax or TV +# licence. The margin of 10p covers the expected Stamp Duty Land Tax that +# household_net_income subtracts (a few pence for these households). +# Payment amounts: SI 2025/969 reg. 3 (Winter Fuel Payment, 2025); SSI +# 2024/351 reg. 10 as made (PAWHP, 2024) and as substituted by SSI 2025/282 +# (2025), with the 2026 amounts from SSI 2026/170. + +- name: 2025 a Scottish pensioner's PAWHP counts in household and HBAI income + # Reg. 10(5)(a): under 80, living alone, £203.40. + period: 2025 + absolute_error_margin: 0.1 + input: + people: + pensioner: + age: 70 + state_pension_reported: 12_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: SCOTLAND + council_tax: 0 + tv_licence: 0 + output: + winter_fuel_allowance: 0 + pawhp: 203.40 + household_benefits: 12_203.40 + household_net_income: 12_203.40 + hbai_benefits: 12_203.40 + hbai_household_net_income: 12_203.40 + +- name: 2025 the same pensioner in England has the Winter Fuel Payment counted the same way + # SI 2025/969 reg. 3(1): under 80, living alone, £200. + period: 2025 + absolute_error_margin: 0.1 + input: + people: + pensioner: + age: 70 + state_pension_reported: 12_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + council_tax: 0 + tv_licence: 0 + output: + winter_fuel_allowance: 200 + pawhp: 0 + household_benefits: 12_200 + household_net_income: 12_200 + hbai_benefits: 12_200 + hbai_household_net_income: 12_200 + +- name: 2026 a Scottish couple under 80 not on a relevant benefit has both shared payments counted + # Reg. 10(5)(b)(i) at the SSI 2026/170 amount: £105.55 each. Neither + # State Pension reaches the personal allowance, so no income tax. + period: 2026 + absolute_error_margin: 0.1 + input: + people: + partner_1: + age: 70 + state_pension_reported: 12_000 + partner_2: + age: 72 + state_pension_reported: 12_000 + benunits: + couple: + members: [partner_1, partner_2] + pension_credit: 0 + households: + household: + members: [partner_1, partner_2] + country: SCOTLAND + council_tax: 0 + tv_licence: 0 + output: + pension_age_winter_heating_payment: [105.55, 105.55] + pawhp: 211.10 + household_benefits: 24_211.10 + household_net_income: 24_211.10 + hbai_benefits: 24_211.10 + hbai_household_net_income: 24_211.10 + +- name: 2024 a Scottish pensioner on Pension Credit has PAWHP counted with the Pension Credit + # Reg. 10(c) as made: under 80 and entitled to a relevant benefit, £200. + period: 2024 + absolute_error_margin: 0.1 + input: + people: + pensioner: + age: 70 + state_pension_reported: 10_000 + benunits: + unit: + members: [pensioner] + pension_credit: 1_000 + households: + household: + members: [pensioner] + country: SCOTLAND + council_tax: 0 + tv_licence: 0 + output: + winter_fuel_allowance: 0 + pawhp: 200 + cost_of_living_support_payment: 0 + household_benefits: 11_200 + household_net_income: 11_200 + hbai_benefits: 11_200 + hbai_household_net_income: 11_200 + +- name: 2023 a Scottish pensioner keeps the Winter Fuel Payment and the pensioner Cost-of-Living Payment + # SI 2000/729 covered Great Britain up to the 2023 qualifying week, so the + # payment is the Winter Fuel Payment (£200 under 80) and there is no PAWHP. + # The £300 Pensioner Cost-of-Living Payment is keyed on the Winter Fuel + # Payment, so counting PAWHP as income leaves it unchanged. + period: 2023 + absolute_error_margin: 0.1 + input: + people: + pensioner: + age: 70 + state_pension_reported: 12_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: SCOTLAND + council_tax: 0 + tv_licence: 0 + output: + winter_fuel_allowance: 200 + pawhp: 0 + cost_of_living_support_payment: 300 diff --git a/policyengine_uk/tests/test_disable_simulated_benefits_winter_heating.py b/policyengine_uk/tests/test_disable_simulated_benefits_winter_heating.py new file mode 100644 index 0000000000..e346b5e13b --- /dev/null +++ b/policyengine_uk/tests/test_disable_simulated_benefits_winter_heating.py @@ -0,0 +1,219 @@ +"""gov.contrib.policyengine.disable_simulated_benefits sets benefits to the +amounts the survey reports. + +The reform could not run: policyengine_uk's Simulation never set +tax_benefit_system.simulation, through which a structural reform reaches the +simulation, and the benefit list named five _reported variables +that no longer exist. These tests build small datasets and turn the reform on +before the data load, which is when structural reforms are created. + +The survey reports one winter heating payment (FRS benefit code 62, in +winter_fuel_allowance_reported) for every respondent, Scotland included. +Invariants, for every household and each of the reform's years: + +1. Conservation: winter_fuel_allowance + pawhp equals the household's + reported winter heating payment, so the report is counted once in + household income (both variables are in household_benefits once). +2. Placement: the report is pawhp in Scotland in the years PAWHP is paid + (from the 2024 qualifying week) and winter_fuel_allowance otherwise. +3. Differential: household_benefits exceeds that of the same dataset with no + reported winter heating payment by the report, plus the pensioner + Cost-of-Living Payment it brings in 2022-23 and 2023-24. +""" + +import numpy as np +import pandas as pd +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import CountryTaxBenefitSystem, Microsimulation, Simulation +from policyengine_uk.data.dataset_schema import UKSingleYearDataset +from policyengine_uk.reforms.policyengine.disable_simulated_benefits import ( + BENEFITS, + YEARS_IN_FUTURE, +) +from policyengine_uk.utils.scenario import Scenario + +SYSTEM = CountryTaxBenefitSystem() +REFORM_ON = Scenario( + applied_before_data_load=True, + parameter_changes={"gov.contrib.policyengine.disable_simulated_benefits": True}, +) +REGIONS = [ + "NORTH_EAST", + "NORTH_WEST", + "YORKSHIRE", + "EAST_MIDLANDS", + "WEST_MIDLANDS", + "EAST_OF_ENGLAND", + "LONDON", + "SOUTH_EAST", + "SOUTH_WEST", + "WALES", + "SCOTLAND", + "NORTHERN_IRELAND", +] +FIRST_PAWHP_YEAR = 2024 +# Simulated benefits with a reported counterpart that the reform does not +# set from BENEFITS, and why. +NOT_IN_BENEFITS = { + "winter_fuel_allowance": "set with pawhp from the one reported winter heating payment", + "maternity_allowance": "already the reported amount (adds maternity_allowance_reported)", + "employee_pension_contributions": "not a benefit", + "tax": "not a benefit", + "bus_fare_spending": "not a benefit (household bus and coach spending)", +} +PROPERTY_SETTINGS = settings( + max_examples=4, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def dataset(year, regions, winter_heating, pension_credit=None): + """One pensioner per household, with a reported State Pension, winter + heating payment and (optionally) Pension Credit.""" + n = len(regions) + ids = np.arange(1, n + 1) + person = pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": [72] * n, + "state_pension_reported": [10_000.0] * n, + "winter_fuel_allowance_reported": [float(x) for x in winter_heating], + "pension_credit_reported": [float(x) for x in (pension_credit or [0] * n)], + } + ) + household = pd.DataFrame( + { + "household_id": ids, + "region": regions, + "household_weight": [1.0] * n, + "tenure_type": ["OWNED_OUTRIGHT"] * n, + "council_tax": [0.0] * n, + "rent": [0.0] * n, + } + ) + return UKSingleYearDataset( + person=person, + benunit=pd.DataFrame({"benunit_id": ids}), + household=household, + fiscal_year=year, + ) + + +def values(simulation, variable, year): + return np.asarray(simulation.calculate(variable, year), dtype=float) + + +def check_winter_heating(simulation, regions, winter_heating, first_year): + reported = np.asarray(winter_heating, dtype=float) + in_scotland = np.asarray(regions) == "SCOTLAND" + for year in range(first_year, first_year + YEARS_IN_FUTURE): + wfa = values(simulation, "winter_fuel_allowance", year) + pawhp = values(simulation, "pawhp", year) + np.testing.assert_allclose(wfa + pawhp, reported, err_msg=str(year)) + paid_as_pawhp = in_scotland & (year >= FIRST_PAWHP_YEAR) + np.testing.assert_allclose( + pawhp, np.where(paid_as_pawhp, reported, 0), err_msg=str(year) + ) + np.testing.assert_allclose( + wfa, np.where(paid_as_pawhp, 0, reported), err_msg=str(year) + ) + + +def test_every_listed_benefit_has_a_reported_amount(): + missing = [name for name in BENEFITS if f"{name}_reported" not in SYSTEM.variables] + assert not missing, missing + + +def test_every_simulated_benefit_with_a_reported_amount_is_set(): + reported = { + name[: -len("_reported")] + for name in SYSTEM.variables + if name.endswith("_reported") and name[: -len("_reported")] in SYSTEM.variables + } + unlisted = reported - set(BENEFITS) - set(NOT_IN_BENEFITS) + assert not unlisted, unlisted + + +@pytest.mark.parametrize("simulation_class", [Simulation, Microsimulation]) +def test_reform_sets_reported_benefits(simulation_class): + """The reform runs, and a listed benefit takes its reported amount.""" + simulation = simulation_class( + dataset=dataset(2023, ["SCOTLAND", "LONDON"], [250, 300], [1_500, 0]), + scenario=REFORM_ON, + ) + for year in [2023, 2026, 2032]: + np.testing.assert_allclose( + values(simulation, "pension_credit", year), [1_500, 0] + ) + np.testing.assert_allclose( + values(simulation, "state_pension", year), [10_000, 10_000] + ) + + +@pytest.mark.parametrize("simulation_class", [Simulation, Microsimulation]) +@pytest.mark.parametrize("first_year", [2023, 2025]) +def test_reported_winter_heating_is_counted_once(simulation_class, first_year): + regions = ["SCOTLAND", "LONDON", "WALES", "NORTHERN_IRELAND", "SCOTLAND"] + winter_heating = [250, 300, 200, 100, 0] + simulation = simulation_class( + dataset=dataset(first_year, regions, winter_heating), scenario=REFORM_ON + ) + check_winter_heating(simulation, regions, winter_heating, first_year) + + +def test_household_benefits_include_the_report_once(): + regions = ["SCOTLAND", "LONDON", "WALES", "NORTHERN_IRELAND"] + winter_heating = [250, 300, 200, 100] + with_report = Simulation( + dataset=dataset(2023, regions, winter_heating), scenario=REFORM_ON + ) + without_report = Simulation( + dataset=dataset(2023, regions, [0] * len(regions)), scenario=REFORM_ON + ) + for year in range(2023, 2023 + YEARS_IN_FUTURE): + change = { + name: values(with_report, name, year) - values(without_report, name, year) + for name in [ + "household_benefits", + "hbai_household_net_income", + "cost_of_living_support_payment", + ] + } + # The pensioner Cost-of-Living Payment is £300 in 2023-24 and keyed + # on the Winter Fuel Payment, which covered Scotland that winter. + expected_col = 300 if year == 2023 else 0 + np.testing.assert_allclose( + change["cost_of_living_support_payment"], expected_col + ) + for name in ["household_benefits", "hbai_household_net_income"]: + np.testing.assert_allclose( + change[name], np.asarray(winter_heating) + expected_col, err_msg=name + ) + + +@PROPERTY_SETTINGS +@given( + first_year=st.sampled_from([2022, 2023, 2024, 2025]), + households=st.lists( + st.tuples( + st.sampled_from(REGIONS), + st.sampled_from([0, 100, 150.5, 200, 250, 300, 600]), + ), + min_size=1, + max_size=6, + ), +) +def test_reported_winter_heating_is_conserved(first_year, households): + regions = [region for region, _ in households] + winter_heating = [amount for _, amount in households] + simulation = Simulation( + dataset=dataset(first_year, regions, winter_heating), scenario=REFORM_ON + ) + check_winter_heating(simulation, regions, winter_heating, first_year) diff --git a/policyengine_uk/tests/test_pawhp_household_income_properties.py b/policyengine_uk/tests/test_pawhp_household_income_properties.py new file mode 100644 index 0000000000..9bcad5425b --- /dev/null +++ b/policyengine_uk/tests/test_pawhp_household_income_properties.py @@ -0,0 +1,166 @@ +"""The Pension Age Winter Heating Payment counts once in household income. + +pawhp was in gov_spending but not in household_benefits, +hbai_household_net_income or hbai_benefits, so Scottish pensioners' winter +heating payment was missing from household net income and HBAI income. + +Differential property, for any population of households in any country and +the 2023 to 2027 qualifying weeks: switching PAWHP off +(gov.social_security_scotland.pawhp.active) lowers household_benefits, +household_gross_income and hbai_benefits by exactly the household's pawhp, +and household_net_income and hbai_household_net_income by its pawhp less +the change in the household's income tax. It changes neither the Winter +Fuel Payment nor the Cost-of-Living Payments (the pensioner payment is +keyed on the Winter Fuel Payment). Before the fix the income measures did +not move at all. + +The income tax term is zero here. It keeps the property true once a charge +on the payment (ITEPA 2003 s.681I, recovering it from people with total +income over £35,000) is modelled in income tax: the net measures then fall +by the payment net of the charge, and income tax falls by the charge, +which is at least zero and at most the payment. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEARS = [2023, 2024, 2025, 2026, 2027] +COUNTRIES = ["ENGLAND", "WALES", "SCOTLAND", "NORTHERN_IRELAND"] +# Either side of pensionable age (66 to 67 over these years) and of 80. +AGES = [40, 60, 67, 70, 79, 80, 85] +GROSS_MEASURES = ["household_benefits", "household_gross_income", "hbai_benefits"] +NET_MEASURES = ["household_net_income", "hbai_household_net_income"] +UNCHANGED = ["winter_fuel_allowance", "cost_of_living_support_payment"] +PAWHP_OFF = {"gov.social_security_scotland.pawhp.active": False} +PROPERTY_SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@st.composite +def benefit_units(draw): + return { + "adults": [ + { + "age": draw(st.sampled_from(AGES)), + "state_pension_reported": draw(st.sampled_from([0, 8_000, 12_000])), + "employment_income": draw(st.sampled_from([0, 0, 15_000, 60_000])), + } + for _ in range(draw(st.integers(1, 2))) + ], + # None leaves Pension Credit to the model's own means test. + "pension_credit": draw(st.sampled_from([None, 0, 1_000])), + } + + +@st.composite +def populations(draw): + return { + "year": draw(st.sampled_from(YEARS)), + "households": [ + { + "country": draw(st.sampled_from(COUNTRIES)), + "units": draw(st.lists(benefit_units(), min_size=1, max_size=2)), + } + for _ in range(draw(st.integers(1, 4))) + ], + } + + +def situation(population): + year = population["year"] + people, benunits, households = {}, {}, {} + for h, household in enumerate(population["households"]): + members = [] + for u, unit in enumerate(household["units"]): + unit_members = [] + for a, adult in enumerate(unit["adults"]): + name = f"h{h}_u{u}_a{a}" + people[name] = {key: {year: value} for key, value in adult.items()} + unit_members.append(name) + benunits[f"h{h}_u{u}"] = {"members": unit_members} + if unit["pension_credit"] is not None: + benunits[f"h{h}_u{u}"]["pension_credit"] = { + year: unit["pension_credit"] + } + members += unit_members + households[f"h{h}"] = { + "members": members, + "country": {year: household["country"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +@PROPERTY_SETTINGS +@given(populations()) +def test_switching_pawhp_off_lowers_household_income_by_pawhp(population): + year = population["year"] + reformed = Simulation(situation=situation(population), reform=PAWHP_OFF) + baseline = reformed.baseline + pawhp = baseline.calculate("pawhp", year) + assert np.all(reformed.calculate("pawhp", year) == 0) + + def change(name): + """The fall in a household total, and the tolerance for comparing it: + 1p plus two float32 steps at the total's size (values are stored as + float32, whose step is 1.6p at £150,000).""" + before = baseline.calculate(name, year, map_to="household") + after = reformed.calculate(name, year, map_to="household") + tolerance = 1e-2 + 2 * np.spacing(np.abs(before).astype(np.float32)) + return before - after, tolerance + + tax_change, tax_tolerance = change("income_tax") + assert np.all(tax_change >= -tax_tolerance) + assert np.all(tax_change <= pawhp + tax_tolerance) + for measure in GROSS_MEASURES: + fall, tolerance = change(measure) + assert np.all(np.abs(fall - pawhp) <= tolerance), measure + for measure in NET_MEASURES: + fall, tolerance = change(measure) + assert np.all(np.abs(fall - (pawhp - tax_change)) <= tolerance), measure + for name in UNCHANGED: + np.testing.assert_allclose( + baseline.calculate(name, year), + reformed.calculate(name, year), + atol=1e-6, + err_msg=name, + ) + + +def test_pawhp_is_paid_in_the_generated_populations(): + """The property is not vacuous: a Scottish pensioner household gets PAWHP + from 2024, and switching it off changes household net income.""" + population = { + "year": 2025, + "households": [ + { + "country": "SCOTLAND", + "units": [ + { + "adults": [ + { + "age": 70, + "state_pension_reported": 12_000, + "employment_income": 0, + } + ], + "pension_credit": 0, + } + ], + } + ], + } + reformed = Simulation(situation=situation(population), reform=PAWHP_OFF) + baseline = reformed.baseline + assert baseline.calculate("pawhp", 2025)[0] > 200 + change = baseline.calculate("household_net_income", 2025) - reformed.calculate( + "household_net_income", 2025 + ) + assert change[0] == pytest.approx(baseline.calculate("pawhp", 2025)[0], abs=1e-2) diff --git a/policyengine_uk/variables/household/income/hbai_benefits.py b/policyengine_uk/variables/household/income/hbai_benefits.py index ac8d146133..69165d87c8 100644 --- a/policyengine_uk/variables/household/income/hbai_benefits.py +++ b/policyengine_uk/variables/household/income/hbai_benefits.py @@ -35,6 +35,7 @@ class hbai_benefits(Variable): "ssmg", "cost_of_living_support_payment", "winter_fuel_allowance", + "pawhp", "tax_free_childcare", "childcare_grant", "parents_learning_allowance", diff --git a/policyengine_uk/variables/household/income/hbai_household_net_income.py b/policyengine_uk/variables/household/income/hbai_household_net_income.py index 5061ab23c1..7f3ff86227 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -45,6 +45,7 @@ "ssmg", "cost_of_living_support_payment", "winter_fuel_allowance", + "pawhp", "tax_free_childcare", "healthy_start_vouchers", "scottish_child_payment", diff --git a/policyengine_uk/variables/household/income/household_benefits.py b/policyengine_uk/variables/household/income/household_benefits.py index 1de62cbaec..7d52631e7a 100644 --- a/policyengine_uk/variables/household/income/household_benefits.py +++ b/policyengine_uk/variables/household/income/household_benefits.py @@ -33,6 +33,7 @@ "cost_of_living_support_payment", "energy_bills_rebate", "winter_fuel_allowance", + "pawhp", "tax_free_childcare", "extended_childcare_entitlement", "universal_childcare_entitlement",