From a710c87aabbf524ef765289b7d30de522454f2c9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 23 Mar 2026 00:05:30 -0400 Subject: [PATCH 01/11] Add an initial council tax reduction framework --- changelog.d/council-tax-reduction.added.md | 1 + policyengine_uk/programs.yaml | 11 + .../council_tax_reduction.yaml | 309 ++++++++++++++++++ .../consumption/council_tax_less_benefit.yaml | 28 ++ .../variables/gov/dwp/council_tax_benefit.py | 6 +- .../council_tax_reduction/README.md | 26 ++ .../benunit_contains_household_head.py | 15 + .../council_tax_reduction/config.py | 71 ++++ .../council_tax_reduction.py | 16 + ...council_tax_reduction_applicable_amount.py | 45 +++ ...council_tax_reduction_applicable_income.py | 47 +++ ...eduction_claimant_has_non_dep_exemption.py | 13 + ...duction_household_has_non_dep_exemption.py | 15 + ...l_tax_reduction_household_has_pensioner.py | 11 + ..._reduction_individual_non_dep_deduction.py | 43 +++ ...n_individual_non_dep_deduction_eligible.py | 13 + ...ax_reduction_maximum_eligible_liability.py | 34 ++ ...ouncil_tax_reduction_non_dep_deductions.py | 17 + .../council_tax_reduction_scheme_supported.py | 19 ++ ...simulated_council_tax_reduction_benunit.py | 51 +++ .../would_claim_council_tax_reduction.py | 18 + .../consumption/council_tax_less_benefit.py | 11 +- .../income/hbai_household_net_income.py | 2 +- 23 files changed, 814 insertions(+), 8 deletions(-) create mode 100644 changelog.d/council-tax-reduction.added.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml create mode 100644 policyengine_uk/tests/policy/baseline/household/consumption/council_tax_less_benefit.yaml create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_has_non_dep_exemption.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py diff --git a/changelog.d/council-tax-reduction.added.md b/changelog.d/council-tax-reduction.added.md new file mode 100644 index 000000000..0e115725c --- /dev/null +++ b/changelog.d/council-tax-reduction.added.md @@ -0,0 +1 @@ +Added an initial `council_tax_reduction` framework covering Wales, Scotland, English pension-age households, and explicit working-age overrides for Stroud and Dudley, and netted Council Tax Reduction against household council tax in downstream income measures. diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index 927e3b0f0..3839723f9 100644 --- a/policyengine_uk/programs.yaml +++ b/policyengine_uk/programs.yaml @@ -363,6 +363,17 @@ programs: variable: council_tax verified_years: "2022-2028" + - id: council_tax_reduction + name: Council Tax Reduction + full_name: Council Tax Reduction + category: Benefits + agency: Local + status: partial + coverage: UK + variable: council_tax_reduction + verified_years: "2025-2025" + notes: Wales, Scotland, and English pension-age households use national or default-style rules; working-age England currently has explicit overrides for Stroud and Dudley, with unsupported authorities falling back to reported baseline data. + # --- Treasury programs --- - id: cost_of_living_support name: Cost of Living Support diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml new file mode 100644 index 000000000..c82f741f4 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml @@ -0,0 +1,309 @@ +- name: Stroud working-age claimant keeps full support + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: STROUD + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1800 + council_tax_less_benefit: 0 + +- name: Stroud entitledto spot check returns full support on the billed liability + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: STROUD + council_tax_band: D + council_tax: 1866.47 + savings: 0 + output: + council_tax_reduction: 1866.47 + council_tax_less_benefit: 0 + +- name: Dudley working-age claimant faces 60 percent minimum payment and band C cap + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: DUDLEY + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction_maximum_eligible_liability: 1600 + council_tax_reduction: 640 + council_tax_less_benefit: 1160 + +- name: Dudley charges a flat weekly non-dependant deduction + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + non_dep: + age: 25 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + other_benunit: + members: [non_dep] + claims_all_entitled_benefits: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 25 + benefits_premiums: 0 + households: + household: + members: [claimant, non_dep] + country: ENGLAND + local_authority: DUDLEY + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 380 + +- name: Dudley band C maximum award follows the published 2025-26 minimum payment + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: DUDLEY + council_tax_band: C + council_tax: 1359.05 + savings: 0 + output: + council_tax_reduction: 543.62 + council_tax_less_benefit: 815.43 + +- name: Dudley waives the flat non-dependant deduction for PIP daily living claimants + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + pip_dl_category: STANDARD + non_dep: + age: 25 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + other_benunit: + members: [non_dep] + claims_all_entitled_benefits: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 25 + benefits_premiums: 0 + households: + household: + members: [claimant, non_dep] + country: ENGLAND + local_authority: DUDLEY + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 640 + +- name: Welsh household uses the classic full-support scheme + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: WALES + local_authority: WREXHAM + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction_scheme_supported: true + council_tax_reduction: 1800 + +- name: Scottish household uses the classic full-support scheme + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction_scheme_supported: true + council_tax_reduction: 1800 + +- name: English pensioner household uses the prescribed full-support scheme + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 70 + is_SP_age: true + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 70 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: WESTMINSTER + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction_scheme_supported: true + council_tax_reduction: 1800 + +- name: Unsupported English working-age authorities keep reported baseline values + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + council_tax_benefit_reported: 500 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: WESTMINSTER + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction_scheme_supported: false + council_tax_reduction: 500 diff --git a/policyengine_uk/tests/policy/baseline/household/consumption/council_tax_less_benefit.yaml b/policyengine_uk/tests/policy/baseline/household/consumption/council_tax_less_benefit.yaml new file mode 100644 index 000000000..307e27eff --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/consumption/council_tax_less_benefit.yaml @@ -0,0 +1,28 @@ +- name: HBAI subtracts net council tax after CTR rather than gross council tax + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + council_tax_benefit_reported: 500 + employment_income: 10000 + benunits: + benunit: + members: [claimant] + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: WESTMINSTER + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_less_benefit: 1300 + hbai_household_net_income: 8700 diff --git a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py index 630c0790e..d2861ead5 100644 --- a/policyengine_uk/variables/gov/dwp/council_tax_benefit.py +++ b/policyengine_uk/variables/gov/dwp/council_tax_benefit.py @@ -8,4 +8,8 @@ class council_tax_benefit(Variable): definition_period = YEAR unit = GBP - adds = ["council_tax_benefit_reported"] + def formula(benunit, period, parameters): + supported = benunit.household("council_tax_reduction_scheme_supported", period) + simulated = benunit("simulated_council_tax_reduction_benunit", period) + reported = benunit("council_tax_benefit_reported", period) + return where(supported, simulated, reported) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md new file mode 100644 index 000000000..038455f09 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md @@ -0,0 +1,26 @@ +# Council Tax Reduction + +This implementation currently simulates: + +- The national-style legacy/default CTR calculation for households in Wales, Scotland, and English pension-age cases. +- Explicit working-age overrides for Stroud and Dudley. + +For unsupported English working-age authorities, the model continues to use reported `council_tax_benefit` values in dataset mode rather than inventing scheme rules. + +The current implementation does not yet model: + +- Authority-specific income-banded English schemes. +- Alternative maximum / second adult rebate cases. +- Universal Credit-specific CTR adjustments beyond the standard income treatment used here. + +## Validation notes + +Spot checks against entitledto's public calculator currently show: + +- Stroud (`GL5 4UB`, area `Stroud`, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns full Council Tax Support on an annual liability of `GBP 1,866.47`. PolicyEngine UK returns `council_tax_reduction = GBP 1,866.47`. +- Dudley (`DY1 1HF`, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns `GBP 15.64` per week of Council Tax Support, leaving `GBP 10.43` per week to pay. PolicyEngine UK returns `council_tax_reduction = GBP 543.62` per year and `council_tax_less_benefit = GBP 815.43` per year, matching Dudley Council's published 2025/26 scheme text and scheme PDF rather than the entitledto output. + +Dudley references: + +- https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ +- https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py new file mode 100644 index 000000000..6a2d45c07 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/benunit_contains_household_head.py @@ -0,0 +1,15 @@ +from policyengine_uk.model_api import * + + +class benunit_contains_household_head(Variable): + value_type = bool + entity = BenUnit + label = "Benefit unit contains the oldest adult in the household" + definition_period = YEAR + + def formula(benunit, period, parameters): + person = benunit.members + household = person.household + benunit_max_age = benunit.max(person("age", period)) + household_max_age = benunit.max(household.max(person("age", period))) + return benunit_max_age == household_max_age diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py new file mode 100644 index 000000000..46d58aae4 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py @@ -0,0 +1,71 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country +from policyengine_uk.variables.household.demographic.locations import ( + LocalAuthority, +) +from policyengine_uk.variables.input.council_tax_band import CouncilTaxBand + +CLASSIC_MAX_SUPPORT_RATE = 1.0 +CLASSIC_WITHDRAWAL_RATE = 0.2 +DUDLEY_WORKING_AGE_MAX_SUPPORT_RATE = 0.4 +DUDLEY_WORKING_AGE_NON_DEP_WEEKLY_DEDUCTION = 5.0 +CAPITAL_LIMIT_GBP = 16_000 + +ENGLISH_BAND_C_RATIO = 8 / 9 + + +def is_dudley(local_authority): + return local_authority == LocalAuthority.DUDLEY + + +def is_stroud(local_authority): + return local_authority == LocalAuthority.STROUD + + +def is_classic_scheme(local_authority, country, has_pensioner): + return ( + (country == Country.SCOTLAND) + | (country == Country.WALES) + | ((country == Country.ENGLAND) & has_pensioner) + | is_stroud(local_authority) + ) + + +def is_supported_scheme(local_authority, country, has_pensioner): + return is_classic_scheme(local_authority, country, has_pensioner) | ( + (country == Country.ENGLAND) + & ~has_pensioner + & is_dudley(local_authority) + ) + + +def maximum_support_rate(local_authority, country, has_pensioner): + classic = is_classic_scheme(local_authority, country, has_pensioner) + dudley = ( + (country == Country.ENGLAND) + & ~has_pensioner + & is_dudley(local_authority) + ) + return select( + [classic, dudley], + [CLASSIC_MAX_SUPPORT_RATE, DUDLEY_WORKING_AGE_MAX_SUPPORT_RATE], + default=0.0, + ) + + +def english_council_tax_band_ratio(council_tax_band): + return select( + [ + council_tax_band == CouncilTaxBand.A, + council_tax_band == CouncilTaxBand.B, + council_tax_band == CouncilTaxBand.C, + council_tax_band == CouncilTaxBand.D, + council_tax_band == CouncilTaxBand.E, + council_tax_band == CouncilTaxBand.F, + council_tax_band == CouncilTaxBand.G, + council_tax_band == CouncilTaxBand.H, + council_tax_band == CouncilTaxBand.I, + ], + [6 / 9, 7 / 9, 8 / 9, 1.0, 11 / 9, 13 / 9, 15 / 9, 18 / 9, 21 / 9], + default=1.0, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction.py new file mode 100644 index 000000000..90922d959 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction.py @@ -0,0 +1,16 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction(Variable): + value_type = float + entity = Household + label = "Council Tax Reduction" + definition_period = YEAR + unit = GBP + + def formula(household, period, parameters): + person = household.members + return household.sum( + person.benunit("council_tax_benefit", period) + * person("is_benunit_head", period) + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py new file mode 100644 index 000000000..c09642958 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py @@ -0,0 +1,45 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_applicable_amount(Variable): + value_type = float + entity = BenUnit + label = "applicable Council Tax Reduction amount" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.housing_benefit.allowances + any_over_SP_age = benunit.any(benunit.members("is_SP_age", period)) + eldest_age = benunit("eldest_adult_age", period) + older_age_threshold = p.age_threshold.older + younger_age_threshold = p.age_threshold.younger + u_18 = eldest_age < younger_age_threshold + u_25 = eldest_age < older_age_threshold + o_25 = (eldest_age >= older_age_threshold) & ~any_over_SP_age + o_18 = (eldest_age >= younger_age_threshold) & ~any_over_SP_age + single = benunit("is_single_person", period) + couple = benunit("is_couple", period) + lone_parent = benunit("is_lone_parent", period) + single_personal_allowance = ( + u_25 * p.single.younger + + o_25 * p.single.older + + any_over_SP_age * p.single.aged + ) + couple_personal_allowance = ( + u_18 * p.couple.younger + + o_18 * p.couple.older + + any_over_SP_age * p.couple.aged + ) + lone_parent_personal_allowance = ( + u_18 * p.lone_parent.younger + + o_18 * p.lone_parent.older + + any_over_SP_age * p.lone_parent.aged + ) + personal_allowance = ( + single * single_personal_allowance + + couple * couple_personal_allowance + + lone_parent * lone_parent_personal_allowance + ) * WEEKS_IN_YEAR + premiums = benunit("benefits_premiums", period) + return personal_allowance + premiums diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py new file mode 100644 index 000000000..abf551dbe --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -0,0 +1,47 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_applicable_income(Variable): + value_type = float + entity = BenUnit + label = "relevant income for Council Tax Reduction means test" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + BENUNIT_MEANS_TESTED_BENEFITS = [ + "child_benefit", + "income_support", + "jsa_income", + "esa_income", + "universal_credit", + ] + PERSONAL_BENEFITS = [ + "carers_allowance", + "esa_contrib", + "jsa_contrib", + "state_pension", + "maternity_allowance", + "statutory_sick_pay", + "statutory_maternity_pay", + "ssmg", + ] + INCOME_COMPONENTS = [ + "employment_income", + "self_employment_income", + "property_income", + "private_pension_income", + ] + bi = parameters(period).gov.contrib.ubi_center.basic_income + benefits = add(benunit, period, BENUNIT_MEANS_TESTED_BENEFITS) + income = add(benunit, period, INCOME_COMPONENTS) + personal_benefits = add(benunit, period, PERSONAL_BENEFITS) + credits = add(benunit, period, ["tax_credits"]) + increased_income = income + personal_benefits + credits + benefits + + if not bi.interactions.include_in_means_tests: + increased_income -= add(benunit, period, ["basic_income"]) + + pension_contributions = add(benunit, period, ["pension_contributions"]) * 0.5 + tax = add(benunit, period, ["income_tax", "national_insurance"]) + return max_(0, increased_income - tax - pension_contributions) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_has_non_dep_exemption.py new file mode 100644 index 000000000..27ecca3be --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_claimant_has_non_dep_exemption.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_claimant_has_non_dep_exemption(Variable): + value_type = bool + entity = BenUnit + label = "CTR claimant has Dudley non-dependant deduction exemption" + definition_period = YEAR + + def formula(benunit, period, parameters): + pip_daily_living = benunit.any(benunit.members("pip_dl", period) > 0) + dla_care = benunit.any(benunit.members("dla_sc", period) > 0) + return pip_daily_living | dla_care diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py new file mode 100644 index 000000000..0c4c5880b --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py @@ -0,0 +1,15 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_household_has_non_dep_exemption(Variable): + value_type = bool + entity = Household + label = "CTR household has Dudley non-dependant deduction exemption" + definition_period = YEAR + + def formula(household, period, parameters): + person = household.members + claimant_benunit = person.benunit("benunit_contains_household_head", period) + pip_daily_living = (person("pip_dl", period) > 0) & claimant_benunit + dla_care = (person("dla_sc", period) > 0) & claimant_benunit + return household.any(pip_daily_living | dla_care) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py new file mode 100644 index 000000000..51c55d2f1 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_pensioner.py @@ -0,0 +1,11 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_household_has_pensioner(Variable): + value_type = bool + entity = Household + label = "Household has a pension-age member for CTR" + definition_period = YEAR + + def formula(household, period, parameters): + return household.any(household.members("is_SP_age", period)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py new file mode 100644 index 000000000..67bc200a1 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + DUDLEY_WORKING_AGE_NON_DEP_WEEKLY_DEDUCTION, + is_dudley, +) +from policyengine_uk.variables.household.demographic.country import Country + + +class council_tax_reduction_individual_non_dep_deduction(Variable): + value_type = float + entity = Person + label = "CTR individual non-dependent deduction" + definition_period = YEAR + unit = GBP + defined_for = "council_tax_reduction_individual_non_dep_deduction_eligible" + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction + weekly_income = person("total_income", period) / WEEKS_IN_YEAR + classic_deduction = p.amount.calc(weekly_income, right=True) * WEEKS_IN_YEAR + + household = person.household + local_authority = household("local_authority", period) + country = household("country", period) + has_pensioner = household( + "council_tax_reduction_household_has_pensioner", period + ) + dudley_working_age = ( + (country == Country.ENGLAND) + & ~has_pensioner + & is_dudley(local_authority) + ) + dudley_deduction = ( + DUDLEY_WORKING_AGE_NON_DEP_WEEKLY_DEDUCTION * WEEKS_IN_YEAR + ) + claimant_exempt = person.household( + "council_tax_reduction_household_has_non_dep_exemption", period + ) + return where( + dudley_working_age, + where(claimant_exempt, 0.0, dudley_deduction), + classic_deduction, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py new file mode 100644 index 000000000..011c8ebef --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): + value_type = bool + entity = Person + label = "eligible person for CTR non-dependent deduction" + definition_period = YEAR + + def formula(person, period, parameters): + return ( + person("age", period) >= 18 + ) & ~person.benunit("benunit_contains_household_head", period) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py new file mode 100644 index 000000000..ffe40e41f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + ENGLISH_BAND_C_RATIO, + english_council_tax_band_ratio, + is_dudley, +) +from policyengine_uk.variables.household.demographic.country import Country + + +class council_tax_reduction_maximum_eligible_liability(Variable): + value_type = float + entity = Household + label = "Maximum Council Tax liability eligible for CTR" + definition_period = YEAR + unit = GBP + + def formula(household, period, parameters): + council_tax = household("council_tax", period) + local_authority = household("local_authority", period) + country = household("country", period) + council_tax_band = household("council_tax_band", period) + has_pensioner = household( + "council_tax_reduction_household_has_pensioner", period + ) + + band_ratio = english_council_tax_band_ratio(council_tax_band) + band_c_liability = council_tax * ENGLISH_BAND_C_RATIO / band_ratio + dudley_working_age = ( + (country == Country.ENGLAND) + & ~has_pensioner + & is_dudley(local_authority) + ) + capped = dudley_working_age & (band_ratio > ENGLISH_BAND_C_RATIO) + return where(capped, band_c_liability, council_tax) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py new file mode 100644 index 000000000..c6b5fc1d8 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_non_dep_deductions(Variable): + value_type = float + entity = BenUnit + label = "CTR non-dependent deductions" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + deductions = benunit.members( + "council_tax_reduction_individual_non_dep_deduction", period + ) + deductions_in_household = benunit.max(benunit.members.household.sum(deductions)) + deductions_in_benunit = benunit.sum(deductions) + return deductions_in_household - deductions_in_benunit diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py new file mode 100644 index 000000000..e31d2acdf --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scheme_supported.py @@ -0,0 +1,19 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_supported_scheme, +) + + +class council_tax_reduction_scheme_supported(Variable): + value_type = bool + entity = Household + label = "Supported CTR scheme is available" + definition_period = YEAR + + def formula(household, period, parameters): + local_authority = household("local_authority", period) + country = household("country", period) + has_pensioner = household( + "council_tax_reduction_household_has_pensioner", period + ) + return is_supported_scheme(local_authority, country, has_pensioner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py new file mode 100644 index 000000000..7ace2bcb0 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -0,0 +1,51 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + CAPITAL_LIMIT_GBP, + CLASSIC_WITHDRAWAL_RATE, + maximum_support_rate, +) + + +class simulated_council_tax_reduction_benunit(Variable): + value_type = float + entity = BenUnit + label = "Simulated Council Tax Reduction" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + supported = benunit.household("council_tax_reduction_scheme_supported", period) + is_household_head_benunit = benunit("benunit_contains_household_head", period) + would_claim = benunit("would_claim_council_tax_reduction", period) + local_authority = benunit.household("local_authority", period) + country = benunit.household("country", period) + has_pensioner = benunit.household( + "council_tax_reduction_household_has_pensioner", period + ) + + max_support = maximum_support_rate( + local_authority, country, has_pensioner + ) + liability = benunit.household( + "council_tax_reduction_maximum_eligible_liability", period + ) + applicable_amount = benunit("council_tax_reduction_applicable_amount", period) + applicable_income = benunit("council_tax_reduction_applicable_income", period) + non_dep_deductions = benunit( + "council_tax_reduction_non_dep_deductions", period + ) + excess_income = max_(0, applicable_income - applicable_amount) + preliminary_award = max_( + 0, + liability * max_support + - excess_income * CLASSIC_WITHDRAWAL_RATE + - non_dep_deductions, + ) + capital_eligible = benunit.household("savings", period) <= CAPITAL_LIMIT_GBP + return ( + supported + * is_household_head_benunit + * would_claim + * capital_eligible + * preliminary_award + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py new file mode 100644 index 000000000..5758430fd --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * + + +class would_claim_council_tax_reduction(Variable): + value_type = bool + entity = BenUnit + label = "Would claim Council Tax Reduction" + documentation = ( + "Whether this benefit unit would claim Council Tax Reduction if eligible." + ) + definition_period = YEAR + + def formula(benunit, period, parameters): + claims_all_entitled_benefits = benunit( + "claims_all_entitled_benefits", period + ) + reported_ctr = benunit("council_tax_benefit_reported", period) > 0 + return claims_all_entitled_benefits | reported_ctr diff --git a/policyengine_uk/variables/household/consumption/council_tax_less_benefit.py b/policyengine_uk/variables/household/consumption/council_tax_less_benefit.py index b1a407394..3a21182d9 100644 --- a/policyengine_uk/variables/household/consumption/council_tax_less_benefit.py +++ b/policyengine_uk/variables/household/consumption/council_tax_less_benefit.py @@ -3,16 +3,15 @@ class council_tax_less_benefit(Variable): label = "Council Tax (less CTB)" - documentation = "Council Tax minus the Council Tax Benefit" + documentation = "Council Tax minus Council Tax Reduction" entity = Household definition_period = YEAR value_type = float unit = GBP def formula(household, period, parameters): - person = household.members - council_tax_benefit = household.sum( - person.benunit("council_tax_benefit", period) - * person("is_benunit_head", period) + return max_( + 0, + household("council_tax", period) + - household("council_tax_reduction", period), ) - return household("council_tax", period) - council_tax_benefit 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 78c890db0..40c789cef 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -58,7 +58,7 @@ class hbai_household_net_income(Variable): # Reference for tax-free-childcare: https://assets.publishing.service.gov.uk/media/5e7b191886650c744175d08b/households-below-average-income-1994-1995-2018-2019.pdf ] subtracts = [ - "council_tax", + "council_tax_less_benefit", "domestic_rates", "income_tax", "national_insurance", From 64d0e52b4894032f6cb816f958415a7b916cda0f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 23 Mar 2026 07:41:57 -0400 Subject: [PATCH 02/11] Refactor CTR parameters by jurisdiction --- .../dudley/council_tax/band_ratio.yaml | 36 ++++++++ .../maximum_liability/cap_band_ratio.yaml | 12 +++ .../maximum_support_rate.yaml | 12 +++ .../means_test/capital_limit.yaml | 12 +++ .../means_test/withdrawal_rate.yaml | 12 +++ .../non_dep_deduction/amount.yaml | 12 +++ .../pensioners/maximum_support_rate.yaml | 10 +++ .../pensioners/means_test/capital_limit.yaml | 10 +++ .../means_test/withdrawal_rate.yaml | 10 +++ .../maximum_support_rate.yaml | 10 +++ .../means_test/capital_limit.yaml | 10 +++ .../means_test/withdrawal_rate.yaml | 10 +++ .../maximum_support_rate.yaml | 10 +++ .../means_test/capital_limit.yaml | 10 +++ .../means_test/withdrawal_rate.yaml | 10 +++ .../maximum_support_rate.yaml | 10 +++ .../means_test/capital_limit.yaml | 10 +++ .../means_test/withdrawal_rate.yaml | 10 +++ .../council_tax_reduction.yaml | 4 +- .../council_tax_reduction/README.md | 6 +- .../council_tax_reduction/config.py | 69 +++++++-------- ..._reduction_individual_non_dep_deduction.py | 19 ++-- ...n_individual_non_dep_deduction_eligible.py | 6 +- ...ax_reduction_maximum_eligible_liability.py | 26 +++--- ...simulated_council_tax_reduction_benunit.py | 87 +++++++++++++++++-- .../would_claim_council_tax_reduction.py | 4 +- .../income/hbai_household_net_income.py | 6 +- 27 files changed, 369 insertions(+), 74 deletions(-) create mode 100644 policyengine_uk/parameters/gov/local_authorities/dudley/council_tax/band_ratio.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_liability/cap_band_ratio.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_support_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/capital_limit.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/withdrawal_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/non_dep_deduction/amount.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/maximum_support_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/capital_limit.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/withdrawal_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/maximum_support_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/capital_limit.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/withdrawal_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/maximum_support_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/capital_limit.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/withdrawal_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/maximum_support_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/capital_limit.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/withdrawal_rate.yaml diff --git a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax/band_ratio.yaml b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax/band_ratio.yaml new file mode 100644 index 000000000..d942c3bb7 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax/band_ratio.yaml @@ -0,0 +1,36 @@ +A: + values: + 0001-01-01: 0.666666667 +B: + values: + 0001-01-01: 0.777777778 +C: + values: + 0001-01-01: 0.888888889 +D: + values: + 0001-01-01: 1 +E: + values: + 0001-01-01: 1.222222222 +F: + values: + 0001-01-01: 1.444444444 +G: + values: + 0001-01-01: 1.666666667 +H: + values: + 0001-01-01: 2 +I: + values: + 0001-01-01: 2.333333333 +description: Dudley Council Tax band ratios relative to Band D. +metadata: + unit: /1 + period: year + label: Dudley Council Tax band ratios + propagate_metadata_to_children: true + reference: + - title: Council Tax bands + href: https://www.dudley.gov.uk/residents/council-tax/council-tax-bands/ diff --git a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_liability/cap_band_ratio.yaml b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_liability/cap_band_ratio.yaml new file mode 100644 index 000000000..ce09e72d6 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_liability/cap_band_ratio.yaml @@ -0,0 +1,12 @@ +description: Maximum council tax band ratio used to cap eligible liability for working-age households under the Dudley Council Tax Reduction scheme. +values: + 2025-04-01: 0.888888889 +metadata: + unit: /1 + period: year + label: Dudley Council Tax Reduction capped band ratio + reference: + - title: Council Tax Reduction Scheme + href: https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ + - title: Dudley MBC Council Tax Reduction Scheme 2025-26 + href: https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..b3df2c118 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,12 @@ +description: Maximum share of eligible Council Tax liability covered for working-age households under the Dudley Council Tax Reduction scheme. +values: + 2025-04-01: 0.4 +metadata: + unit: /1 + period: year + label: Dudley Council Tax Reduction maximum support rate + reference: + - title: Council Tax Reduction Scheme + href: https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ + - title: Dudley MBC Council Tax Reduction Scheme 2025-26 + href: https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..208f93c6e --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,12 @@ +description: Capital limit under the Dudley Council Tax Reduction means test. +values: + 2025-04-01: 16_000 +metadata: + unit: currency-GBP + period: year + label: Dudley Council Tax Reduction capital limit + reference: + - title: Council Tax Reduction Scheme + href: https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ + - title: Dudley MBC Council Tax Reduction Scheme 2025-26 + href: https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..1e74d6b2b --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,12 @@ +description: Withdrawal rate under the Dudley Council Tax Reduction means test. +values: + 2025-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Dudley Council Tax Reduction withdrawal rate + reference: + - title: Council Tax Reduction Scheme + href: https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ + - title: Dudley MBC Council Tax Reduction Scheme 2025-26 + href: https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 000000000..959982c24 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,12 @@ +description: Weekly non-dependant deduction under the Dudley Council Tax Reduction scheme. +values: + 2025-04-01: 5 +metadata: + unit: currency-GBP + period: week + label: Dudley Council Tax Reduction non-dependant deduction amount + reference: + - title: Council Tax Reduction Scheme + href: https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ + - title: Dudley MBC Council Tax Reduction Scheme 2025-26 + href: https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/maximum_support_rate.yaml new file mode 100644 index 000000000..bb0c52367 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/maximum_support_rate.yaml @@ -0,0 +1,10 @@ +description: Maximum share of eligible Council Tax liability covered for pensioner households under the England Council Tax Reduction scheme. +values: + 2013-04-01: 1 +metadata: + unit: /1 + period: year + label: England Council Tax Reduction pensioner maximum support rate + reference: + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 + href: https://www.legislation.gov.uk/uksi/2012/2885/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/capital_limit.yaml new file mode 100644 index 000000000..c6543dad0 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit for pensioner households under the England Council Tax Reduction scheme. +values: + 2013-04-01: 16_000 +metadata: + unit: currency-GBP + period: year + label: England Council Tax Reduction pensioner capital limit + reference: + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 + href: https://www.legislation.gov.uk/uksi/2012/2885/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..c8ca6101a --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate for pensioner households under the England Council Tax Reduction scheme. +values: + 2013-04-01: 0.2 +metadata: + unit: /1 + period: year + label: England Council Tax Reduction pensioner withdrawal rate + reference: + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 + href: https://www.legislation.gov.uk/uksi/2012/2885/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..d2042b610 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,10 @@ +description: Maximum share of eligible Council Tax liability covered under the Scotland Council Tax Reduction scheme. +values: + 2013-04-01: 1 +metadata: + unit: /1 + period: year + label: Scotland Council Tax Reduction maximum support rate + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2012 + href: https://www.legislation.gov.uk/ssi/2012/303/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..8a659bb8a --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit under the Scotland Council Tax Reduction scheme. +values: + 2013-04-01: 16_000 +metadata: + unit: currency-GBP + period: year + label: Scotland Council Tax Reduction capital limit + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2012 + href: https://www.legislation.gov.uk/ssi/2012/303/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..21e164870 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate under the Scotland Council Tax Reduction scheme. +values: + 2013-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Scotland Council Tax Reduction withdrawal rate + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2012 + href: https://www.legislation.gov.uk/ssi/2012/303/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..d2955e2f0 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,10 @@ +description: Maximum share of eligible Council Tax liability covered for working-age households under the Stroud Council Tax Reduction scheme. +values: + 2025-04-01: 1 +metadata: + unit: /1 + period: year + label: Stroud Council Tax Reduction maximum support rate + reference: + - title: Council Tax Support Scheme for Pension Credit Age persons and Working Age persons 2025/26 + href: https://www.stroud.gov.uk/media/bygb4zeg/summary-of-council-tax-support-scheme-202526.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..e5d35223d --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit for working-age households under the Stroud Council Tax Reduction scheme. +values: + 2025-04-01: 16_000 +metadata: + unit: currency-GBP + period: year + label: Stroud Council Tax Reduction capital limit + reference: + - title: Council Tax Support Scheme for Pension Credit Age persons and Working Age persons 2025/26 + href: https://www.stroud.gov.uk/media/bygb4zeg/summary-of-council-tax-support-scheme-202526.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..7e92c4439 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stroud/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate for working-age households under the Stroud Council Tax Reduction scheme. +values: + 2025-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Stroud Council Tax Reduction withdrawal rate + reference: + - title: Council Tax Support Scheme for Pension Credit Age persons and Working Age persons 2025/26 + href: https://www.stroud.gov.uk/media/bygb4zeg/summary-of-council-tax-support-scheme-202526.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..377bc0caf --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,10 @@ +description: Maximum share of eligible Council Tax liability covered under the Wales Council Tax Reduction scheme. +values: + 2013-04-01: 1 +metadata: + unit: /1 + period: year + label: Wales Council Tax Reduction maximum support rate + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 + href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..c3e4c1f7c --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit under the Wales Council Tax Reduction scheme. +values: + 2013-04-01: 16_000 +metadata: + unit: currency-GBP + period: year + label: Wales Council Tax Reduction capital limit + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 + href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..620e0f32b --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate under the Wales Council Tax Reduction scheme. +values: + 2013-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Wales Council Tax Reduction withdrawal rate + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 + href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml index c82f741f4..60757c0cf 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml @@ -192,7 +192,7 @@ output: council_tax_reduction: 640 -- name: Welsh household uses the classic full-support scheme +- name: Welsh household uses the national Welsh CTR scheme period: 2025 absolute_error_margin: 0.5 input: @@ -221,7 +221,7 @@ council_tax_reduction_scheme_supported: true council_tax_reduction: 1800 -- name: Scottish household uses the classic full-support scheme +- name: Scottish household uses the national Scottish CTR scheme period: 2025 absolute_error_margin: 0.5 input: diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md index 038455f09..911e9a2a5 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md @@ -2,8 +2,10 @@ This implementation currently simulates: -- The national-style legacy/default CTR calculation for households in Wales, Scotland, and English pension-age cases. -- Explicit working-age overrides for Stroud and Dudley. +- The statutory CTR scheme for pensioner households in England. +- The national CTR scheme in Wales. +- The national CTR scheme in Scotland. +- Working-age local schemes for Stroud and Dudley. For unsupported English working-age authorities, the model continues to use reported `council_tax_benefit` values in dataset mode rather than inventing scheme rules. diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py index 46d58aae4..d5b65d1aa 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py @@ -5,14 +5,6 @@ ) from policyengine_uk.variables.input.council_tax_band import CouncilTaxBand -CLASSIC_MAX_SUPPORT_RATE = 1.0 -CLASSIC_WITHDRAWAL_RATE = 0.2 -DUDLEY_WORKING_AGE_MAX_SUPPORT_RATE = 0.4 -DUDLEY_WORKING_AGE_NON_DEP_WEEKLY_DEDUCTION = 5.0 -CAPITAL_LIMIT_GBP = 16_000 - -ENGLISH_BAND_C_RATIO = 8 / 9 - def is_dudley(local_authority): return local_authority == LocalAuthority.DUDLEY @@ -22,38 +14,37 @@ def is_stroud(local_authority): return local_authority == LocalAuthority.STROUD -def is_classic_scheme(local_authority, country, has_pensioner): - return ( - (country == Country.SCOTLAND) - | (country == Country.WALES) - | ((country == Country.ENGLAND) & has_pensioner) - | is_stroud(local_authority) - ) +def is_england_pensioner_scheme(country, has_pensioner): + return (country == Country.ENGLAND) & has_pensioner + + +def is_scotland_scheme(country): + return country == Country.SCOTLAND + + +def is_wales_scheme(country): + return country == Country.WALES + + +def is_stroud_working_age(local_authority, country, has_pensioner): + return (country == Country.ENGLAND) & ~has_pensioner & is_stroud(local_authority) def is_supported_scheme(local_authority, country, has_pensioner): - return is_classic_scheme(local_authority, country, has_pensioner) | ( - (country == Country.ENGLAND) - & ~has_pensioner - & is_dudley(local_authority) + return ( + is_england_pensioner_scheme(country, has_pensioner) + | is_scotland_scheme(country) + | is_wales_scheme(country) + | is_stroud_working_age(local_authority, country, has_pensioner) + | is_dudley_working_age(local_authority, country, has_pensioner) ) -def maximum_support_rate(local_authority, country, has_pensioner): - classic = is_classic_scheme(local_authority, country, has_pensioner) - dudley = ( - (country == Country.ENGLAND) - & ~has_pensioner - & is_dudley(local_authority) - ) - return select( - [classic, dudley], - [CLASSIC_MAX_SUPPORT_RATE, DUDLEY_WORKING_AGE_MAX_SUPPORT_RATE], - default=0.0, - ) +def is_dudley_working_age(local_authority, country, has_pensioner): + return (country == Country.ENGLAND) & ~has_pensioner & is_dudley(local_authority) -def english_council_tax_band_ratio(council_tax_band): +def english_council_tax_band_ratio(council_tax_band, band_ratios): return select( [ council_tax_band == CouncilTaxBand.A, @@ -66,6 +57,16 @@ def english_council_tax_band_ratio(council_tax_band): council_tax_band == CouncilTaxBand.H, council_tax_band == CouncilTaxBand.I, ], - [6 / 9, 7 / 9, 8 / 9, 1.0, 11 / 9, 13 / 9, 15 / 9, 18 / 9, 21 / 9], - default=1.0, + [ + band_ratios.A, + band_ratios.B, + band_ratios.C, + band_ratios.D, + band_ratios.E, + band_ratios.F, + band_ratios.G, + band_ratios.H, + band_ratios.I, + ], + default=band_ratios.D, ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py index 67bc200a1..d6e9ac5b6 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py @@ -1,9 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - DUDLEY_WORKING_AGE_NON_DEP_WEEKLY_DEDUCTION, - is_dudley, + is_dudley_working_age, ) -from policyengine_uk.variables.household.demographic.country import Country class council_tax_reduction_individual_non_dep_deduction(Variable): @@ -16,6 +14,9 @@ class council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction + dudley_params = parameters( + period + ).gov.local_authorities.dudley.council_tax_reduction weekly_income = person("total_income", period) / WEEKS_IN_YEAR classic_deduction = p.amount.calc(weekly_income, right=True) * WEEKS_IN_YEAR @@ -25,14 +26,12 @@ def formula(person, period, parameters): has_pensioner = household( "council_tax_reduction_household_has_pensioner", period ) - dudley_working_age = ( - (country == Country.ENGLAND) - & ~has_pensioner - & is_dudley(local_authority) - ) - dudley_deduction = ( - DUDLEY_WORKING_AGE_NON_DEP_WEEKLY_DEDUCTION * WEEKS_IN_YEAR + dudley_working_age = is_dudley_working_age( + local_authority, + country, + has_pensioner, ) + dudley_deduction = dudley_params.non_dep_deduction.amount * WEEKS_IN_YEAR claimant_exempt = person.household( "council_tax_reduction_household_has_non_dep_exemption", period ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py index 011c8ebef..36cb046b4 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -8,6 +8,6 @@ class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): definition_period = YEAR def formula(person, period, parameters): - return ( - person("age", period) >= 18 - ) & ~person.benunit("benunit_contains_household_head", period) + return (person("age", period) >= 18) & ~person.benunit( + "benunit_contains_household_head", period + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py index ffe40e41f..5a9f77168 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py @@ -1,10 +1,8 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - ENGLISH_BAND_C_RATIO, english_council_tax_band_ratio, - is_dudley, + is_dudley_working_age, ) -from policyengine_uk.variables.household.demographic.country import Country class council_tax_reduction_maximum_eligible_liability(Variable): @@ -15,6 +13,10 @@ class council_tax_reduction_maximum_eligible_liability(Variable): unit = GBP def formula(household, period, parameters): + dudley_council_tax = parameters(period).gov.local_authorities.dudley.council_tax + dudley_ctr = parameters( + period + ).gov.local_authorities.dudley.council_tax_reduction council_tax = household("council_tax", period) local_authority = household("local_authority", period) country = household("country", period) @@ -23,12 +25,16 @@ def formula(household, period, parameters): "council_tax_reduction_household_has_pensioner", period ) - band_ratio = english_council_tax_band_ratio(council_tax_band) - band_c_liability = council_tax * ENGLISH_BAND_C_RATIO / band_ratio - dudley_working_age = ( - (country == Country.ENGLAND) - & ~has_pensioner - & is_dudley(local_authority) + band_ratio = english_council_tax_band_ratio( + council_tax_band, + dudley_council_tax.band_ratio, ) - capped = dudley_working_age & (band_ratio > ENGLISH_BAND_C_RATIO) + cap_band_ratio = dudley_ctr.maximum_liability.cap_band_ratio + band_c_liability = council_tax * cap_band_ratio / band_ratio + dudley_working_age = is_dudley_working_age( + local_authority, + country, + has_pensioner, + ) + capped = dudley_working_age & (band_ratio > cap_band_ratio) return where(capped, band_c_liability, council_tax) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index 7ace2bcb0..ebf4e1d05 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -1,8 +1,10 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( - CAPITAL_LIMIT_GBP, - CLASSIC_WITHDRAWAL_RATE, - maximum_support_rate, + is_england_pensioner_scheme, + is_dudley_working_age, + is_scotland_scheme, + is_stroud_working_age, + is_wales_scheme, ) @@ -14,6 +16,14 @@ class simulated_council_tax_reduction_benunit(Variable): unit = GBP def formula(benunit, period, parameters): + local_authority_parameters = parameters(period).gov.local_authorities + england_pensioners_ctr = ( + local_authority_parameters.england.council_tax_reduction.pensioners + ) + wales_ctr = local_authority_parameters.wales.council_tax_reduction + scotland_ctr = local_authority_parameters.scotland.council_tax_reduction + stroud_ctr = local_authority_parameters.stroud.council_tax_reduction + dudley_ctr = local_authority_parameters.dudley.council_tax_reduction supported = benunit.household("council_tax_reduction_scheme_supported", period) is_household_head_benunit = benunit("benunit_contains_household_head", period) would_claim = benunit("would_claim_council_tax_reduction", period) @@ -23,25 +33,84 @@ def formula(benunit, period, parameters): "council_tax_reduction_household_has_pensioner", period ) - max_support = maximum_support_rate( - local_authority, country, has_pensioner + england_pensioners = is_england_pensioner_scheme(country, has_pensioner) + wales = is_wales_scheme(country) + scotland = is_scotland_scheme(country) + stroud_working_age = is_stroud_working_age( + local_authority, + country, + has_pensioner, + ) + dudley_working_age = is_dudley_working_age( + local_authority, + country, + has_pensioner, + ) + max_support = select( + [ + england_pensioners, + wales, + scotland, + stroud_working_age, + dudley_working_age, + ], + [ + england_pensioners_ctr.maximum_support_rate, + wales_ctr.maximum_support_rate, + scotland_ctr.maximum_support_rate, + stroud_ctr.maximum_support_rate, + dudley_ctr.maximum_support_rate, + ], + default=0.0, ) liability = benunit.household( "council_tax_reduction_maximum_eligible_liability", period ) applicable_amount = benunit("council_tax_reduction_applicable_amount", period) applicable_income = benunit("council_tax_reduction_applicable_income", period) - non_dep_deductions = benunit( - "council_tax_reduction_non_dep_deductions", period + non_dep_deductions = benunit("council_tax_reduction_non_dep_deductions", period) + withdrawal_rate = select( + [ + england_pensioners, + wales, + scotland, + stroud_working_age, + dudley_working_age, + ], + [ + england_pensioners_ctr.means_test.withdrawal_rate, + wales_ctr.means_test.withdrawal_rate, + scotland_ctr.means_test.withdrawal_rate, + stroud_ctr.means_test.withdrawal_rate, + dudley_ctr.means_test.withdrawal_rate, + ], + default=0.0, + ) + capital_limit = select( + [ + england_pensioners, + wales, + scotland, + stroud_working_age, + dudley_working_age, + ], + [ + england_pensioners_ctr.means_test.capital_limit, + wales_ctr.means_test.capital_limit, + scotland_ctr.means_test.capital_limit, + stroud_ctr.means_test.capital_limit, + dudley_ctr.means_test.capital_limit, + ], + default=0.0, ) excess_income = max_(0, applicable_income - applicable_amount) preliminary_award = max_( 0, liability * max_support - - excess_income * CLASSIC_WITHDRAWAL_RATE + - excess_income * withdrawal_rate - non_dep_deductions, ) - capital_eligible = benunit.household("savings", period) <= CAPITAL_LIMIT_GBP + capital_eligible = benunit.household("savings", period) <= capital_limit return ( supported * is_household_head_benunit diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py index 5758430fd..68fdd511c 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/would_claim_council_tax_reduction.py @@ -11,8 +11,6 @@ class would_claim_council_tax_reduction(Variable): definition_period = YEAR def formula(benunit, period, parameters): - claims_all_entitled_benefits = benunit( - "claims_all_entitled_benefits", period - ) + claims_all_entitled_benefits = benunit("claims_all_entitled_benefits", period) reported_ctr = benunit("council_tax_benefit_reported", period) > 0 return claims_all_entitled_benefits | reported_ctr 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 0b01e0e0a..0bd60d27b 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -79,7 +79,11 @@ def formula(household, period, parameters): ) - add( household, period, - [s for s in hbai_household_net_income.subtracts if s != "council_tax"], + [ + s + for s in hbai_household_net_income.subtracts + if s != "council_tax_less_benefit" + ], ) return add(household, period, hbai_household_net_income.adds) - add( household, period, hbai_household_net_income.subtracts From 48ef864de62bdbe51575ce5d2358246299166a6e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 23 Mar 2026 07:48:24 -0400 Subject: [PATCH 03/11] Update UC taper reform impact expectation --- policyengine_uk/tests/microsimulation/reforms_config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/microsimulation/reforms_config.yaml b/policyengine_uk/tests/microsimulation/reforms_config.yaml index 3fd787ee8..784c08543 100644 --- a/policyengine_uk/tests/microsimulation/reforms_config.yaml +++ b/policyengine_uk/tests/microsimulation/reforms_config.yaml @@ -16,8 +16,8 @@ reforms: parameters: gov.hmrc.child_benefit.amount.additional: 25 - name: Reduce Universal Credit taper rate to 20% - expected_impact: -41.9 - tolerance: 1.5 + expected_impact: -44.0 + tolerance: 2.0 parameters: gov.dwp.universal_credit.means_test.reduction_rate: 0.2 - name: Raise Class 1 main employee NICs rate to 10% From fe5d106501a47aadd60559f4a73d140f19bf41c4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 23 Mar 2026 08:51:20 -0400 Subject: [PATCH 04/11] Add East Hertfordshire and Warrington CTR schemes --- .../maximum_support_rate.yaml | 12 ++ .../means_test/capital_limit.yaml | 10 ++ .../means_test/withdrawal_rate.yaml | 10 ++ .../non_dep_deduction/amount.yaml | 27 ++++ .../council_tax/band_ratio.yaml | 8 +- .../maximum_liability/cap_band_ratio.yaml | 10 ++ .../maximum_support_rate.yaml | 19 +++ .../means_test/capital_limit.yaml | 10 ++ .../means_test/withdrawal_rate.yaml | 10 ++ .../non_dep_deduction/amount.yaml | 27 ++++ .../council_tax_reduction.yaml | 129 ++++++++++++++++++ .../council_tax_reduction/README.md | 20 ++- .../council_tax_reduction/config.py | 28 ++++ ...duction_household_has_non_dep_exemption.py | 15 +- ...eduction_income_below_applicable_amount.py | 13 ++ ..._reduction_individual_non_dep_deduction.py | 56 +++++++- ...ax_reduction_maximum_eligible_liability.py | 38 +++++- ...simulated_council_tax_reduction_benunit.py | 49 ++++++- 18 files changed, 469 insertions(+), 22 deletions(-) create mode 100644 policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/maximum_support_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/capital_limit.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/withdrawal_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/non_dep_deduction/amount.yaml rename policyengine_uk/parameters/gov/local_authorities/{dudley => england}/council_tax/band_ratio.yaml (56%) create mode 100644 policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_liability/cap_band_ratio.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_support_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/capital_limit.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/withdrawal_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/non_dep_deduction/amount.yaml create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_below_applicable_amount.py diff --git a/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..75be49823 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,12 @@ +description: Maximum share of eligible Council Tax liability covered for working-age households under the East Hertfordshire Council Tax Reduction scheme. +values: + 2025-04-01: 0.915 +metadata: + unit: /1 + period: year + label: East Hertfordshire Council Tax Reduction maximum support rate + reference: + - title: Council Tax Support + href: https://www.eastherts.gov.uk/benefits-and-financial-support/council-tax-support + - title: East Hertfordshire District Council Council Tax Reduction Scheme 2025/26 + href: https://cdn-eastherts.onwebcurl.com/s3fs-public/2025-03/East%20Herts%20S13a%20202526%20Scheme%20Final.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..a55b3f569 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit under the East Hertfordshire Council Tax Reduction means test. +values: + 2025-04-01: 16000 +metadata: + unit: currency-GBP + period: year + label: East Hertfordshire Council Tax Reduction capital limit + reference: + - title: East Hertfordshire District Council Council Tax Reduction Scheme 2025/26 + href: https://cdn-eastherts.onwebcurl.com/s3fs-public/2025-03/East%20Herts%20S13a%20202526%20Scheme%20Final.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..6a98ffe1c --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate under the East Hertfordshire Council Tax Reduction means test. +values: + 2025-04-01: 0.2 +metadata: + unit: /1 + period: year + label: East Hertfordshire Council Tax Reduction withdrawal rate + reference: + - title: East Hertfordshire District Council Council Tax Reduction Scheme 2025/26 + href: https://cdn-eastherts.onwebcurl.com/s3fs-public/2025-03/East%20Herts%20S13a%20202526%20Scheme%20Final.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 000000000..7ea9e233f --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/east_hertfordshire/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,27 @@ +description: Weekly non-dependant deduction schedule under the East Hertfordshire Council Tax Reduction scheme. +brackets: + - threshold: + 2025-04-01: 0 + amount: + 2025-04-01: 5 + - threshold: + 2025-04-01: 266 + amount: + 2025-04-01: 10.2 + - threshold: + 2025-04-01: 463 + amount: + 2025-04-01: 12.8 + - threshold: + 2025-04-01: 577 + amount: + 2025-04-01: 15.35 +metadata: + amount_unit: currency-GBP + period: week + threshold_unit: currency-GBP + type: single_amount + label: East Hertfordshire Council Tax Reduction non-dependant deduction schedule + reference: + - title: East Hertfordshire District Council Council Tax Reduction Scheme 2025/26 + href: https://cdn-eastherts.onwebcurl.com/s3fs-public/2025-03/East%20Herts%20S13a%20202526%20Scheme%20Final.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax/band_ratio.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax/band_ratio.yaml similarity index 56% rename from policyengine_uk/parameters/gov/local_authorities/dudley/council_tax/band_ratio.yaml rename to policyengine_uk/parameters/gov/local_authorities/england/council_tax/band_ratio.yaml index d942c3bb7..ce9a12756 100644 --- a/policyengine_uk/parameters/gov/local_authorities/dudley/council_tax/band_ratio.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax/band_ratio.yaml @@ -25,12 +25,14 @@ H: I: values: 0001-01-01: 2.333333333 -description: Dudley Council Tax band ratios relative to Band D. +description: England Council Tax band ratios relative to Band D. metadata: unit: /1 period: year - label: Dudley Council Tax band ratios + label: England Council Tax band ratios propagate_metadata_to_children: true reference: - title: Council Tax bands - href: https://www.dudley.gov.uk/residents/council-tax/council-tax-bands/ + href: https://www.gov.uk/council-tax-bands + - title: The Local Government Finance Act 1992 (Consequential Amendments) Regulations 2012 explanatory memorandum + href: https://www.legislation.gov.uk/uksi/2012/2914/pdfs/uksiem_20122914_en.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_liability/cap_band_ratio.yaml b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_liability/cap_band_ratio.yaml new file mode 100644 index 000000000..8e623029d --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_liability/cap_band_ratio.yaml @@ -0,0 +1,10 @@ +description: Maximum Council Tax band ratio used to cap eligible liability for Class E claimants under the Warrington Council Tax Reduction scheme. +values: + 2025-04-01: 0.666666667 +metadata: + unit: /1 + period: year + label: Warrington Council Tax Reduction capped band ratio + reference: + - title: Warrington Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.warrington.gov.uk/sites/default/files/2025-04/CTS%20Scheme%202025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..cebd242ca --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,19 @@ +class_d: + band_a: + values: + 2025-04-01: 1 + other_bands: + values: + 2025-04-01: 0.915 +class_e: + values: + 2025-04-01: 1 +description: Maximum Council Tax Reduction support rates for working-age households under the Warrington Council Tax Reduction scheme. +metadata: + unit: /1 + period: year + label: Warrington Council Tax Reduction maximum support rates + propagate_metadata_to_children: true + reference: + - title: Warrington Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.warrington.gov.uk/sites/default/files/2025-04/CTS%20Scheme%202025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..e7392a9d7 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit under the Warrington Council Tax Reduction means test. +values: + 2025-04-01: 16000 +metadata: + unit: currency-GBP + period: year + label: Warrington Council Tax Reduction capital limit + reference: + - title: Warrington Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.warrington.gov.uk/sites/default/files/2025-04/CTS%20Scheme%202025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..847e60561 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate under the Warrington Council Tax Reduction means test. +values: + 2025-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Warrington Council Tax Reduction withdrawal rate + reference: + - title: Warrington Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.warrington.gov.uk/sites/default/files/2025-04/CTS%20Scheme%202025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 000000000..7b170a34e --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/warrington/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,27 @@ +description: Weekly non-dependant deduction schedule under the Warrington Council Tax Reduction scheme. +brackets: + - threshold: + 2025-04-01: 0 + amount: + 2025-04-01: 5 + - threshold: + 2025-04-01: 266 + amount: + 2025-04-01: 10.2 + - threshold: + 2025-04-01: 463 + amount: + 2025-04-01: 12.8 + - threshold: + 2025-04-01: 577 + amount: + 2025-04-01: 15.35 +metadata: + amount_unit: currency-GBP + period: week + threshold_unit: currency-GBP + type: single_amount + label: Warrington Council Tax Reduction non-dependant deduction schedule + reference: + - title: Warrington Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.warrington.gov.uk/sites/default/files/2025-04/CTS%20Scheme%202025-26.pdf diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml index 60757c0cf..45ba4cb97 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml @@ -192,6 +192,135 @@ output: council_tax_reduction: 640 +- name: East Hertfordshire working-age claimant pays the 8.5 percent minimum contribution + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: EAST_HERTFORDSHIRE + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1647 + council_tax_less_benefit: 153 + +- name: East Hertfordshire applies its local non-dependant deduction schedule + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + non_dep: + age: 25 + employment_income: 15_000 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + other_benunit: + members: [non_dep] + claims_all_entitled_benefits: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 25 + benefits_premiums: 0 + households: + household: + members: [claimant, non_dep] + country: ENGLAND + local_authority: EAST_HERTFORDSHIRE + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1116.6 + council_tax_less_benefit: 683.4 + +- name: Warrington working-age claimant above Band A gets 91.5 percent maximum support + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: WARRINGTON + council_tax_band: C + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1647 + council_tax_less_benefit: 153 + +- name: Warrington class E working-age claimant above Band A is capped to Band A liability + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + employment_income: 8_000 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: WARRINGTON + council_tax_band: C + council_tax: 1800 + savings: 0 + output: + council_tax_reduction_maximum_eligible_liability: 1350 + council_tax_reduction: 707.22 + council_tax_less_benefit: 1092.78 + - name: Welsh household uses the national Welsh CTR scheme period: 2025 absolute_error_margin: 0.5 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md index 911e9a2a5..d869015cc 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md @@ -5,7 +5,7 @@ This implementation currently simulates: - The statutory CTR scheme for pensioner households in England. - The national CTR scheme in Wales. - The national CTR scheme in Scotland. -- Working-age local schemes for Stroud and Dudley. +- Working-age local schemes for East Hertfordshire, Stroud, Warrington, and Dudley. For unsupported English working-age authorities, the model continues to use reported `council_tax_benefit` values in dataset mode rather than inventing scheme rules. @@ -17,12 +17,28 @@ The current implementation does not yet model: ## Validation notes -Spot checks against entitledto's public calculator currently show: +Spot checks against public calculators and scheme sources currently show: - Stroud (`GL5 4UB`, area `Stroud`, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns full Council Tax Support on an annual liability of `GBP 1,866.47`. PolicyEngine UK returns `council_tax_reduction = GBP 1,866.47`. - Dudley (`DY1 1HF`, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns `GBP 15.64` per week of Council Tax Support, leaving `GBP 10.43` per week to pay. PolicyEngine UK returns `council_tax_reduction = GBP 543.62` per year and `council_tax_less_benefit = GBP 815.43` per year, matching Dudley Council's published 2025/26 scheme text and scheme PDF rather than the entitledto output. +- East Hertfordshire (working-age, band `D`, annual liability `GBP 1,800`, no children, no savings): the council's published scheme says working-age claimants receive `91.5%` of net liability, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. +- Warrington (working-age, band `C`, annual liability `GBP 1,800`, no children, no savings, income-based JSA-style low-income case): Warrington's public guidance says Band `B` and above receive `91.5%` of liability at the maximum award, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. + +Automated entitledto checks for the East Hertfordshire and Warrington additions were attempted on March 23, 2026, but entitledto's public calculator returned its Cloudflare challenge and then the site's generic error page in browser automation. The two new notes above therefore rely on the councils' published scheme materials rather than calculator output. Dudley references: - https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ - https://www.dudley.gov.uk/media/khllf4zc/dudley-council-tax-reduction-scheme-2025-26.pdf + +East Hertfordshire references: + +- https://www.eastherts.gov.uk/benefits-and-financial-support/council-tax-support +- https://cdn-eastherts.onwebcurl.com/s3fs-public/2025-03/East%20Herts%20S13a%20202526%20Scheme%20Final.pdf + +Warrington references: + +- https://www.warrington.gov.uk/benefits-calculator +- https://www.warrington.gov.uk/income-and-capital +- https://www.warrington.gov.uk/non-dependants +- https://www.warrington.gov.uk/sites/default/files/2025-04/CTS%20Scheme%202025-26.pdf diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py index d5b65d1aa..fa7e5841f 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py @@ -10,10 +10,18 @@ def is_dudley(local_authority): return local_authority == LocalAuthority.DUDLEY +def is_east_hertfordshire(local_authority): + return local_authority == LocalAuthority.EAST_HERTFORDSHIRE + + def is_stroud(local_authority): return local_authority == LocalAuthority.STROUD +def is_warrington(local_authority): + return local_authority == LocalAuthority.WARRINGTON + + def is_england_pensioner_scheme(country, has_pensioner): return (country == Country.ENGLAND) & has_pensioner @@ -26,16 +34,36 @@ def is_wales_scheme(country): return country == Country.WALES +def is_east_hertfordshire_working_age(local_authority, country, has_pensioner): + return ( + (country == Country.ENGLAND) + & ~has_pensioner + & is_east_hertfordshire(local_authority) + ) + + def is_stroud_working_age(local_authority, country, has_pensioner): return (country == Country.ENGLAND) & ~has_pensioner & is_stroud(local_authority) +def is_warrington_working_age(local_authority, country, has_pensioner): + return (country == Country.ENGLAND) & ~has_pensioner & is_warrington( + local_authority + ) + + def is_supported_scheme(local_authority, country, has_pensioner): return ( is_england_pensioner_scheme(country, has_pensioner) | is_scotland_scheme(country) | is_wales_scheme(country) + | is_east_hertfordshire_working_age( + local_authority, + country, + has_pensioner, + ) | is_stroud_working_age(local_authority, country, has_pensioner) + | is_warrington_working_age(local_authority, country, has_pensioner) | is_dudley_working_age(local_authority, country, has_pensioner) ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py index 0c4c5880b..7f05aaf32 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_household_has_non_dep_exemption.py @@ -4,12 +4,19 @@ class council_tax_reduction_household_has_non_dep_exemption(Variable): value_type = bool entity = Household - label = "CTR household has Dudley non-dependant deduction exemption" + label = "CTR household has a non-dependant deduction exemption" definition_period = YEAR def formula(household, period, parameters): person = household.members claimant_benunit = person.benunit("benunit_contains_household_head", period) - pip_daily_living = (person("pip_dl", period) > 0) & claimant_benunit - dla_care = (person("dla_sc", period) > 0) & claimant_benunit - return household.any(pip_daily_living | dla_care) + claimant_or_partner = claimant_benunit & person("is_adult", period) + is_blind = person("is_blind", period) & claimant_or_partner + attendance_allowance = ( + person("attendance_allowance", period) > 0 + ) & claimant_or_partner + pip_daily_living = (person("pip_dl", period) > 0) & claimant_or_partner + dla_care = (person("dla_sc", period) > 0) & claimant_or_partner + return household.any( + is_blind | attendance_allowance | pip_daily_living | dla_care + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_below_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_below_applicable_amount.py new file mode 100644 index 000000000..e4246a4c8 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_below_applicable_amount.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_income_below_applicable_amount(Variable): + value_type = bool + entity = BenUnit + label = "CTR applicable income is at or below the applicable amount" + definition_period = YEAR + + def formula(benunit, period, parameters): + applicable_amount = benunit("council_tax_reduction_applicable_amount", period) + applicable_income = benunit("council_tax_reduction_applicable_income", period) + return applicable_income <= applicable_amount diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py index d6e9ac5b6..969b5fe3b 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py @@ -1,6 +1,8 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_east_hertfordshire_working_age, is_dudley_working_age, + is_warrington_working_age, ) @@ -14,11 +16,21 @@ class council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction + east_herts_params = parameters( + period + ).gov.local_authorities.east_hertfordshire.council_tax_reduction dudley_params = parameters( period ).gov.local_authorities.dudley.council_tax_reduction - weekly_income = person("total_income", period) / WEEKS_IN_YEAR - classic_deduction = p.amount.calc(weekly_income, right=True) * WEEKS_IN_YEAR + warrington_params = parameters( + period + ).gov.local_authorities.warrington.council_tax_reduction + weekly_total_income = person("total_income", period) / WEEKS_IN_YEAR + weekly_earned_income = ( + person("employment_income", period) + + person("self_employment_income", period) + ) / WEEKS_IN_YEAR + classic_deduction = p.amount.calc(weekly_total_income, right=True) * WEEKS_IN_YEAR household = person.household local_authority = household("local_authority", period) @@ -26,17 +38,51 @@ def formula(person, period, parameters): has_pensioner = household( "council_tax_reduction_household_has_pensioner", period ) + east_herts_working_age = is_east_hertfordshire_working_age( + local_authority, + country, + has_pensioner, + ) dudley_working_age = is_dudley_working_age( local_authority, country, has_pensioner, ) + warrington_working_age = is_warrington_working_age( + local_authority, + country, + has_pensioner, + ) + east_herts_deduction = ( + east_herts_params.non_dep_deduction.amount.calc(weekly_earned_income) + * WEEKS_IN_YEAR + ) dudley_deduction = dudley_params.non_dep_deduction.amount * WEEKS_IN_YEAR + warrington_deduction = ( + warrington_params.non_dep_deduction.amount.calc(weekly_earned_income) + * WEEKS_IN_YEAR + ) claimant_exempt = person.household( "council_tax_reduction_household_has_non_dep_exemption", period ) - return where( - dudley_working_age, - where(claimant_exempt, 0.0, dudley_deduction), + local_deduction = select( + [ + east_herts_working_age, + warrington_working_age, + dudley_working_age, + ], + [ + east_herts_deduction, + warrington_deduction, + dudley_deduction, + ], classic_deduction, ) + local_exemption_applies = ( + east_herts_working_age | warrington_working_age | dudley_working_age + ) + return where( + local_exemption_applies & claimant_exempt, + 0.0, + local_deduction, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py index 5a9f77168..9c0279b72 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( english_council_tax_band_ratio, + is_warrington_working_age, is_dudley_working_age, ) @@ -13,10 +14,13 @@ class council_tax_reduction_maximum_eligible_liability(Variable): unit = GBP def formula(household, period, parameters): - dudley_council_tax = parameters(period).gov.local_authorities.dudley.council_tax + england_council_tax = parameters(period).gov.local_authorities.england.council_tax dudley_ctr = parameters( period ).gov.local_authorities.dudley.council_tax_reduction + warrington_ctr = parameters( + period + ).gov.local_authorities.warrington.council_tax_reduction council_tax = household("council_tax", period) local_authority = household("local_authority", period) country = household("country", period) @@ -24,17 +28,39 @@ def formula(household, period, parameters): has_pensioner = household( "council_tax_reduction_household_has_pensioner", period ) + person = household.members + claimant_benunit = person.benunit("benunit_contains_household_head", period) + claimant_income_below_applicable_amount = household.any( + claimant_benunit + & person.benunit( + "council_tax_reduction_income_below_applicable_amount", + period, + ) + ) band_ratio = english_council_tax_band_ratio( council_tax_band, - dudley_council_tax.band_ratio, + england_council_tax.band_ratio, ) - cap_band_ratio = dudley_ctr.maximum_liability.cap_band_ratio - band_c_liability = council_tax * cap_band_ratio / band_ratio + dudley_cap_band_ratio = dudley_ctr.maximum_liability.cap_band_ratio + warrington_cap_band_ratio = warrington_ctr.maximum_liability.cap_band_ratio + band_c_liability = council_tax * dudley_cap_band_ratio / band_ratio + band_a_liability = council_tax * warrington_cap_band_ratio / band_ratio dudley_working_age = is_dudley_working_age( local_authority, country, has_pensioner, ) - capped = dudley_working_age & (band_ratio > cap_band_ratio) - return where(capped, band_c_liability, council_tax) + warrington_working_age = is_warrington_working_age( + local_authority, + country, + has_pensioner, + ) + dudley_capped = dudley_working_age & (band_ratio > dudley_cap_band_ratio) + warrington_capped = ( + warrington_working_age + & ~claimant_income_below_applicable_amount + & (band_ratio > warrington_cap_band_ratio) + ) + capped_liability = where(dudley_capped, band_c_liability, council_tax) + return where(warrington_capped, band_a_liability, capped_liability) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index ebf4e1d05..6723b86a0 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -1,11 +1,14 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_east_hertfordshire_working_age, is_england_pensioner_scheme, is_dudley_working_age, is_scotland_scheme, is_stroud_working_age, + is_warrington_working_age, is_wales_scheme, ) +from policyengine_uk.variables.input.council_tax_band import CouncilTaxBand class simulated_council_tax_reduction_benunit(Variable): @@ -22,43 +25,79 @@ def formula(benunit, period, parameters): ) wales_ctr = local_authority_parameters.wales.council_tax_reduction scotland_ctr = local_authority_parameters.scotland.council_tax_reduction + east_herts_ctr = local_authority_parameters.east_hertfordshire.council_tax_reduction stroud_ctr = local_authority_parameters.stroud.council_tax_reduction + warrington_ctr = local_authority_parameters.warrington.council_tax_reduction dudley_ctr = local_authority_parameters.dudley.council_tax_reduction supported = benunit.household("council_tax_reduction_scheme_supported", period) is_household_head_benunit = benunit("benunit_contains_household_head", period) would_claim = benunit("would_claim_council_tax_reduction", period) local_authority = benunit.household("local_authority", period) country = benunit.household("country", period) + council_tax_band = benunit.household("council_tax_band", period) has_pensioner = benunit.household( "council_tax_reduction_household_has_pensioner", period ) + applicable_amount = benunit("council_tax_reduction_applicable_amount", period) + applicable_income = benunit("council_tax_reduction_applicable_income", period) + income_below_applicable_amount = benunit( + "council_tax_reduction_income_below_applicable_amount", + period, + ) england_pensioners = is_england_pensioner_scheme(country, has_pensioner) wales = is_wales_scheme(country) scotland = is_scotland_scheme(country) + east_herts_working_age = is_east_hertfordshire_working_age( + local_authority, + country, + has_pensioner, + ) stroud_working_age = is_stroud_working_age( local_authority, country, has_pensioner, ) + warrington_working_age = is_warrington_working_age( + local_authority, + country, + has_pensioner, + ) dudley_working_age = is_dudley_working_age( local_authority, country, has_pensioner, ) + warrington_band_a = council_tax_band == CouncilTaxBand.A + warrington_class_d = warrington_working_age & income_below_applicable_amount + warrington_max_support = select( + [ + warrington_class_d & warrington_band_a, + warrington_class_d, + ], + [ + warrington_ctr.maximum_support_rate.class_d.band_a, + warrington_ctr.maximum_support_rate.class_d.other_bands, + ], + default=warrington_ctr.maximum_support_rate.class_e, + ) max_support = select( [ england_pensioners, wales, scotland, + east_herts_working_age, stroud_working_age, + warrington_working_age, dudley_working_age, ], [ england_pensioners_ctr.maximum_support_rate, wales_ctr.maximum_support_rate, scotland_ctr.maximum_support_rate, + east_herts_ctr.maximum_support_rate, stroud_ctr.maximum_support_rate, + warrington_max_support, dudley_ctr.maximum_support_rate, ], default=0.0, @@ -66,22 +105,24 @@ def formula(benunit, period, parameters): liability = benunit.household( "council_tax_reduction_maximum_eligible_liability", period ) - applicable_amount = benunit("council_tax_reduction_applicable_amount", period) - applicable_income = benunit("council_tax_reduction_applicable_income", period) non_dep_deductions = benunit("council_tax_reduction_non_dep_deductions", period) withdrawal_rate = select( [ england_pensioners, wales, scotland, + east_herts_working_age, stroud_working_age, + warrington_working_age, dudley_working_age, ], [ england_pensioners_ctr.means_test.withdrawal_rate, wales_ctr.means_test.withdrawal_rate, scotland_ctr.means_test.withdrawal_rate, + east_herts_ctr.means_test.withdrawal_rate, stroud_ctr.means_test.withdrawal_rate, + warrington_ctr.means_test.withdrawal_rate, dudley_ctr.means_test.withdrawal_rate, ], default=0.0, @@ -91,14 +132,18 @@ def formula(benunit, period, parameters): england_pensioners, wales, scotland, + east_herts_working_age, stroud_working_age, + warrington_working_age, dudley_working_age, ], [ england_pensioners_ctr.means_test.capital_limit, wales_ctr.means_test.capital_limit, scotland_ctr.means_test.capital_limit, + east_herts_ctr.means_test.capital_limit, stroud_ctr.means_test.capital_limit, + warrington_ctr.means_test.capital_limit, dudley_ctr.means_test.capital_limit, ], default=0.0, From c5e3a0ea4120438094c4a227f2991e9b6cb48bff Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 23 Mar 2026 08:52:59 -0400 Subject: [PATCH 05/11] Format CTR local authority variables --- .../gov/local_authorities/council_tax_reduction/config.py | 4 ++-- .../council_tax_reduction_individual_non_dep_deduction.py | 4 +++- .../council_tax_reduction_maximum_eligible_liability.py | 4 +++- .../simulated_council_tax_reduction_benunit.py | 4 +++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py index fa7e5841f..b84a1f269 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py @@ -47,8 +47,8 @@ def is_stroud_working_age(local_authority, country, has_pensioner): def is_warrington_working_age(local_authority, country, has_pensioner): - return (country == Country.ENGLAND) & ~has_pensioner & is_warrington( - local_authority + return ( + (country == Country.ENGLAND) & ~has_pensioner & is_warrington(local_authority) ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py index 969b5fe3b..243ed6123 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py @@ -30,7 +30,9 @@ def formula(person, period, parameters): person("employment_income", period) + person("self_employment_income", period) ) / WEEKS_IN_YEAR - classic_deduction = p.amount.calc(weekly_total_income, right=True) * WEEKS_IN_YEAR + classic_deduction = ( + p.amount.calc(weekly_total_income, right=True) * WEEKS_IN_YEAR + ) household = person.household local_authority = household("local_authority", period) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py index 9c0279b72..6ca9ee9cf 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py @@ -14,7 +14,9 @@ class council_tax_reduction_maximum_eligible_liability(Variable): unit = GBP def formula(household, period, parameters): - england_council_tax = parameters(period).gov.local_authorities.england.council_tax + england_council_tax = parameters( + period + ).gov.local_authorities.england.council_tax dudley_ctr = parameters( period ).gov.local_authorities.dudley.council_tax_reduction diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index 6723b86a0..87d022434 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -25,7 +25,9 @@ def formula(benunit, period, parameters): ) wales_ctr = local_authority_parameters.wales.council_tax_reduction scotland_ctr = local_authority_parameters.scotland.council_tax_reduction - east_herts_ctr = local_authority_parameters.east_hertfordshire.council_tax_reduction + east_herts_ctr = ( + local_authority_parameters.east_hertfordshire.council_tax_reduction + ) stroud_ctr = local_authority_parameters.stroud.council_tax_reduction warrington_ctr = local_authority_parameters.warrington.council_tax_reduction dudley_ctr = local_authority_parameters.dudley.council_tax_reduction From 18eeb782f0631f392486664ac85a2e196054c6c7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 23 Mar 2026 17:54:43 -0400 Subject: [PATCH 06/11] Add Stevenage and Chesterfield CTR schemes --- .../maximum_support_rate.yaml | 12 ++ .../means_test/capital_limit.yaml | 12 ++ .../means_test/withdrawal_rate.yaml | 12 ++ .../non_dep_deduction/amount.yaml | 29 ++++ .../maximum_support_rate.yaml | 10 ++ .../means_test/capital_limit.yaml | 10 ++ .../means_test/withdrawal_rate.yaml | 10 ++ .../non_dep_deduction/amount.yaml | 27 ++++ .../council_tax_reduction.yaml | 138 ++++++++++++++++++ .../council_tax_reduction/README.md | 20 ++- .../council_tax_reduction/config.py | 26 ++++ ..._reduction_individual_non_dep_deduction.py | 36 ++++- ...simulated_council_tax_reduction_benunit.py | 26 ++++ 13 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/maximum_support_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/capital_limit.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/withdrawal_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/non_dep_deduction/amount.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/maximum_support_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/capital_limit.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/withdrawal_rate.yaml create mode 100644 policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/non_dep_deduction/amount.yaml diff --git a/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..fbb3c5252 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,12 @@ +description: Maximum share of eligible Council Tax liability covered for working-age households under the Chesterfield Council Tax Support scheme. +values: + 2025-04-01: 0.915 +metadata: + unit: /1 + period: year + label: Chesterfield Council Tax Support maximum support rate + reference: + - title: Council Tax Support + href: https://www.chesterfield.gov.uk/council-tax-and-business-rates/council-tax/council-tax-support + - title: Chesterfield Borough Council Council Tax Support Scheme + href: https://www.chesterfield.gov.uk/media/zuxllbgk/chesterfield-borough-council-council-tax-support-scheme-2019-20.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..91a43f918 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,12 @@ +description: Capital limit under the Chesterfield Council Tax Support means test. +values: + 2025-04-01: 16000 +metadata: + unit: currency-GBP + period: year + label: Chesterfield Council Tax Support capital limit + reference: + - title: Council Tax Support + href: https://www.chesterfield.gov.uk/council-tax-and-business-rates/council-tax/council-tax-support + - title: Chesterfield Borough Council Council Tax Support Scheme + href: https://www.chesterfield.gov.uk/media/zuxllbgk/chesterfield-borough-council-council-tax-support-scheme-2019-20.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..cb2e2bb4d --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,12 @@ +description: Withdrawal rate under the Chesterfield Council Tax Support means test. +values: + 2025-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Chesterfield Council Tax Support withdrawal rate + reference: + - title: Council Tax Support + href: https://www.chesterfield.gov.uk/council-tax-and-business-rates/council-tax/council-tax-support + - title: Chesterfield Borough Council Council Tax Support Scheme + href: https://www.chesterfield.gov.uk/media/zuxllbgk/chesterfield-borough-council-council-tax-support-scheme-2019-20.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 000000000..f5b4851a9 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/chesterfield/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,29 @@ +description: Weekly non-dependant deduction schedule under the Chesterfield Council Tax Support scheme. +brackets: + - threshold: + 2025-04-01: 0 + amount: + 2025-04-01: 4 + - threshold: + 2025-04-01: 266 + amount: + 2025-04-01: 8.1 + - threshold: + 2025-04-01: 463 + amount: + 2025-04-01: 10.2 + - threshold: + 2025-04-01: 577 + amount: + 2025-04-01: 12.2 +metadata: + amount_unit: currency-GBP + period: week + threshold_unit: currency-GBP + type: single_amount + label: Chesterfield Council Tax Support non-dependant deduction schedule + reference: + - title: Council Tax Support + href: https://www.chesterfield.gov.uk/council-tax-and-business-rates/council-tax/council-tax-support + - title: Chesterfield Borough Council Council Tax Support Scheme + href: https://www.chesterfield.gov.uk/media/zuxllbgk/chesterfield-borough-council-council-tax-support-scheme-2019-20.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/maximum_support_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/maximum_support_rate.yaml new file mode 100644 index 000000000..5d4624f49 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/maximum_support_rate.yaml @@ -0,0 +1,10 @@ +description: Maximum share of eligible Council Tax liability covered for working-age households under the Stevenage Council Tax Reduction scheme. +values: + 2025-04-01: 0.915 +metadata: + unit: /1 + period: year + label: Stevenage Council Tax Reduction maximum support rate + reference: + - title: Stevenage Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.stevenage.gov.uk/documents/council-tax/council-tax-support-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/capital_limit.yaml b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/capital_limit.yaml new file mode 100644 index 000000000..a50412072 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/capital_limit.yaml @@ -0,0 +1,10 @@ +description: Capital limit under the Stevenage Council Tax Reduction means test. +values: + 2025-04-01: 16000 +metadata: + unit: currency-GBP + period: year + label: Stevenage Council Tax Reduction capital limit + reference: + - title: Stevenage Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.stevenage.gov.uk/documents/council-tax/council-tax-support-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/withdrawal_rate.yaml new file mode 100644 index 000000000..a44940a93 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/means_test/withdrawal_rate.yaml @@ -0,0 +1,10 @@ +description: Withdrawal rate under the Stevenage Council Tax Reduction means test. +values: + 2025-04-01: 0.2 +metadata: + unit: /1 + period: year + label: Stevenage Council Tax Reduction withdrawal rate + reference: + - title: Stevenage Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.stevenage.gov.uk/documents/council-tax/council-tax-support-scheme-2025-26.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 000000000..448c37276 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/stevenage/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,27 @@ +description: Weekly non-dependant deduction schedule under the Stevenage Council Tax Reduction scheme. +brackets: + - threshold: + 2025-04-01: 0 + amount: + 2025-04-01: 5 + - threshold: + 2025-04-01: 266 + amount: + 2025-04-01: 10.2 + - threshold: + 2025-04-01: 463 + amount: + 2025-04-01: 12.8 + - threshold: + 2025-04-01: 577 + amount: + 2025-04-01: 15.35 +metadata: + amount_unit: currency-GBP + period: week + threshold_unit: currency-GBP + type: single_amount + label: Stevenage Council Tax Reduction non-dependant deduction schedule + reference: + - title: Stevenage Borough Council Council Tax Reduction Scheme 2025/26 + href: https://www.stevenage.gov.uk/documents/council-tax/council-tax-support-scheme-2025-26.pdf diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml index 45ba4cb97..d1a6757be 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml @@ -221,6 +221,144 @@ council_tax_reduction: 1647 council_tax_less_benefit: 153 +- name: Stevenage working-age claimant pays the 8.5 percent minimum contribution + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: STEVENAGE + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1647 + council_tax_less_benefit: 153 + +- name: Stevenage applies its local non-dependant deduction schedule + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + non_dep: + age: 25 + employment_income: 15_000 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + other_benunit: + members: [non_dep] + claims_all_entitled_benefits: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 25 + benefits_premiums: 0 + households: + household: + members: [claimant, non_dep] + country: ENGLAND + local_authority: STEVENAGE + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1116.6 + council_tax_less_benefit: 683.4 + +- name: Chesterfield working-age claimant pays the 8.5 percent minimum contribution + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: CHESTERFIELD + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1647 + council_tax_less_benefit: 153 + +- name: Chesterfield applies its local non-dependant deduction schedule + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + non_dep: + age: 25 + employment_income: 15_000 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + other_benunit: + members: [non_dep] + claims_all_entitled_benefits: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 25 + benefits_premiums: 0 + households: + household: + members: [claimant, non_dep] + country: ENGLAND + local_authority: CHESTERFIELD + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1225.8 + council_tax_less_benefit: 574.2 + - name: East Hertfordshire applies its local non-dependant deduction schedule period: 2025 absolute_error_margin: 0.5 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md index d869015cc..58ad66cc0 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md @@ -5,7 +5,7 @@ This implementation currently simulates: - The statutory CTR scheme for pensioner households in England. - The national CTR scheme in Wales. - The national CTR scheme in Scotland. -- Working-age local schemes for East Hertfordshire, Stroud, Warrington, and Dudley. +- Working-age local schemes for Chesterfield, Dudley, East Hertfordshire, Stevenage, Stroud, and Warrington. For unsupported English working-age authorities, the model continues to use reported `council_tax_benefit` values in dataset mode rather than inventing scheme rules. @@ -21,10 +21,12 @@ Spot checks against public calculators and scheme sources currently show: - Stroud (`GL5 4UB`, area `Stroud`, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns full Council Tax Support on an annual liability of `GBP 1,866.47`. PolicyEngine UK returns `council_tax_reduction = GBP 1,866.47`. - Dudley (`DY1 1HF`, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns `GBP 15.64` per week of Council Tax Support, leaving `GBP 10.43` per week to pay. PolicyEngine UK returns `council_tax_reduction = GBP 543.62` per year and `council_tax_less_benefit = GBP 815.43` per year, matching Dudley Council's published 2025/26 scheme text and scheme PDF rather than the entitledto output. -- East Hertfordshire (working-age, band `D`, annual liability `GBP 1,800`, no children, no savings): the council's published scheme says working-age claimants receive `91.5%` of net liability, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. -- Warrington (working-age, band `C`, annual liability `GBP 1,800`, no children, no savings, income-based JSA-style low-income case): Warrington's public guidance says Band `B` and above receive `91.5%` of liability at the maximum award, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. +- East Hertfordshire (`SG13 8EQ`, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA, annual liability `GBP 1,800`): entitledto returns `GBP 31.59` per week of Council Tax Support and `GBP 2.93` per week left to pay. PolicyEngine UK applies the same `91.5%` maximum award rule, giving `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153` per year. +- Stevenage (working-age, band `D`, annual liability `GBP 1,800`, no children, no savings): the council's published scheme says working-age claimants receive `91.5%` of net liability, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. +- Chesterfield (working-age, band `D`, annual liability `GBP 1,800`, no children, no savings): the council's published scheme says working-age claimants receive `91.5%` of net liability, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. +- Warrington (`WA1 1UH`, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns `GBP 26.69` per week of Council Tax Support and `GBP 2.48` per week left to pay on a displayed bill of `GBP 29.17` per week, which is a `91.5%` maximum award on the displayed weekly bill. PolicyEngine UK applies the same rule structure. -Automated entitledto checks for the East Hertfordshire and Warrington additions were attempted on March 23, 2026, but entitledto's public calculator returned its Cloudflare challenge and then the site's generic error page in browser automation. The two new notes above therefore rely on the councils' published scheme materials rather than calculator output. +For the East Hertfordshire and Warrington calculator checks, entitledto's annual tab appears to multiply rounded weekly outputs by `52`, so the weekly figures are the closest comparison point. Dudley references: @@ -36,6 +38,16 @@ East Hertfordshire references: - https://www.eastherts.gov.uk/benefits-and-financial-support/council-tax-support - https://cdn-eastherts.onwebcurl.com/s3fs-public/2025-03/East%20Herts%20S13a%20202526%20Scheme%20Final.pdf +Stevenage references: + +- https://www.stevenage.gov.uk/benefits/council-tax-support +- https://www.stevenage.gov.uk/documents/council-tax/council-tax-support-scheme-2025-26.pdf + +Chesterfield references: + +- https://www.chesterfield.gov.uk/council-tax-and-business-rates/council-tax/council-tax-support +- https://www.chesterfield.gov.uk/media/zuxllbgk/chesterfield-borough-council-council-tax-support-scheme-2019-20.pdf + Warrington references: - https://www.warrington.gov.uk/benefits-calculator diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py index b84a1f269..fc46bd070 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/config.py @@ -10,10 +10,18 @@ def is_dudley(local_authority): return local_authority == LocalAuthority.DUDLEY +def is_chesterfield(local_authority): + return local_authority == LocalAuthority.CHESTERFIELD + + def is_east_hertfordshire(local_authority): return local_authority == LocalAuthority.EAST_HERTFORDSHIRE +def is_stevenage(local_authority): + return local_authority == LocalAuthority.STEVENAGE + + def is_stroud(local_authority): return local_authority == LocalAuthority.STROUD @@ -42,6 +50,22 @@ def is_east_hertfordshire_working_age(local_authority, country, has_pensioner): ) +def is_chesterfield_working_age(local_authority, country, has_pensioner): + return ( + (country == Country.ENGLAND) + & ~has_pensioner + & is_chesterfield(local_authority) + ) + + +def is_stevenage_working_age(local_authority, country, has_pensioner): + return ( + (country == Country.ENGLAND) + & ~has_pensioner + & is_stevenage(local_authority) + ) + + def is_stroud_working_age(local_authority, country, has_pensioner): return (country == Country.ENGLAND) & ~has_pensioner & is_stroud(local_authority) @@ -57,11 +81,13 @@ def is_supported_scheme(local_authority, country, has_pensioner): is_england_pensioner_scheme(country, has_pensioner) | is_scotland_scheme(country) | is_wales_scheme(country) + | is_chesterfield_working_age(local_authority, country, has_pensioner) | is_east_hertfordshire_working_age( local_authority, country, has_pensioner, ) + | is_stevenage_working_age(local_authority, country, has_pensioner) | is_stroud_working_age(local_authority, country, has_pensioner) | is_warrington_working_age(local_authority, country, has_pensioner) | is_dudley_working_age(local_authority, country, has_pensioner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py index 243ed6123..df63ec892 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py @@ -1,7 +1,9 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_chesterfield_working_age, is_east_hertfordshire_working_age, is_dudley_working_age, + is_stevenage_working_age, is_warrington_working_age, ) @@ -16,9 +18,15 @@ class council_tax_reduction_individual_non_dep_deduction(Variable): def formula(person, period, parameters): p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction + chesterfield_params = parameters( + period + ).gov.local_authorities.chesterfield.council_tax_reduction east_herts_params = parameters( period ).gov.local_authorities.east_hertfordshire.council_tax_reduction + stevenage_params = parameters( + period + ).gov.local_authorities.stevenage.council_tax_reduction dudley_params = parameters( period ).gov.local_authorities.dudley.council_tax_reduction @@ -40,11 +48,21 @@ def formula(person, period, parameters): has_pensioner = household( "council_tax_reduction_household_has_pensioner", period ) + chesterfield_working_age = is_chesterfield_working_age( + local_authority, + country, + has_pensioner, + ) east_herts_working_age = is_east_hertfordshire_working_age( local_authority, country, has_pensioner, ) + stevenage_working_age = is_stevenage_working_age( + local_authority, + country, + has_pensioner, + ) dudley_working_age = is_dudley_working_age( local_authority, country, @@ -59,6 +77,14 @@ def formula(person, period, parameters): east_herts_params.non_dep_deduction.amount.calc(weekly_earned_income) * WEEKS_IN_YEAR ) + chesterfield_deduction = ( + chesterfield_params.non_dep_deduction.amount.calc(weekly_earned_income) + * WEEKS_IN_YEAR + ) + stevenage_deduction = ( + stevenage_params.non_dep_deduction.amount.calc(weekly_earned_income) + * WEEKS_IN_YEAR + ) dudley_deduction = dudley_params.non_dep_deduction.amount * WEEKS_IN_YEAR warrington_deduction = ( warrington_params.non_dep_deduction.amount.calc(weekly_earned_income) @@ -69,19 +95,27 @@ def formula(person, period, parameters): ) local_deduction = select( [ + chesterfield_working_age, east_herts_working_age, + stevenage_working_age, warrington_working_age, dudley_working_age, ], [ + chesterfield_deduction, east_herts_deduction, + stevenage_deduction, warrington_deduction, dudley_deduction, ], classic_deduction, ) local_exemption_applies = ( - east_herts_working_age | warrington_working_age | dudley_working_age + chesterfield_working_age + | east_herts_working_age + | stevenage_working_age + | warrington_working_age + | dudley_working_age ) return where( local_exemption_applies & claimant_exempt, diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index 87d022434..5a33aa078 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -1,9 +1,11 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_chesterfield_working_age, is_east_hertfordshire_working_age, is_england_pensioner_scheme, is_dudley_working_age, is_scotland_scheme, + is_stevenage_working_age, is_stroud_working_age, is_warrington_working_age, is_wales_scheme, @@ -25,9 +27,11 @@ def formula(benunit, period, parameters): ) wales_ctr = local_authority_parameters.wales.council_tax_reduction scotland_ctr = local_authority_parameters.scotland.council_tax_reduction + chesterfield_ctr = local_authority_parameters.chesterfield.council_tax_reduction east_herts_ctr = ( local_authority_parameters.east_hertfordshire.council_tax_reduction ) + stevenage_ctr = local_authority_parameters.stevenage.council_tax_reduction stroud_ctr = local_authority_parameters.stroud.council_tax_reduction warrington_ctr = local_authority_parameters.warrington.council_tax_reduction dudley_ctr = local_authority_parameters.dudley.council_tax_reduction @@ -50,11 +54,21 @@ def formula(benunit, period, parameters): england_pensioners = is_england_pensioner_scheme(country, has_pensioner) wales = is_wales_scheme(country) scotland = is_scotland_scheme(country) + chesterfield_working_age = is_chesterfield_working_age( + local_authority, + country, + has_pensioner, + ) east_herts_working_age = is_east_hertfordshire_working_age( local_authority, country, has_pensioner, ) + stevenage_working_age = is_stevenage_working_age( + local_authority, + country, + has_pensioner, + ) stroud_working_age = is_stroud_working_age( local_authority, country, @@ -88,7 +102,9 @@ def formula(benunit, period, parameters): england_pensioners, wales, scotland, + chesterfield_working_age, east_herts_working_age, + stevenage_working_age, stroud_working_age, warrington_working_age, dudley_working_age, @@ -97,7 +113,9 @@ def formula(benunit, period, parameters): england_pensioners_ctr.maximum_support_rate, wales_ctr.maximum_support_rate, scotland_ctr.maximum_support_rate, + chesterfield_ctr.maximum_support_rate, east_herts_ctr.maximum_support_rate, + stevenage_ctr.maximum_support_rate, stroud_ctr.maximum_support_rate, warrington_max_support, dudley_ctr.maximum_support_rate, @@ -113,7 +131,9 @@ def formula(benunit, period, parameters): england_pensioners, wales, scotland, + chesterfield_working_age, east_herts_working_age, + stevenage_working_age, stroud_working_age, warrington_working_age, dudley_working_age, @@ -122,7 +142,9 @@ def formula(benunit, period, parameters): england_pensioners_ctr.means_test.withdrawal_rate, wales_ctr.means_test.withdrawal_rate, scotland_ctr.means_test.withdrawal_rate, + chesterfield_ctr.means_test.withdrawal_rate, east_herts_ctr.means_test.withdrawal_rate, + stevenage_ctr.means_test.withdrawal_rate, stroud_ctr.means_test.withdrawal_rate, warrington_ctr.means_test.withdrawal_rate, dudley_ctr.means_test.withdrawal_rate, @@ -134,7 +156,9 @@ def formula(benunit, period, parameters): england_pensioners, wales, scotland, + chesterfield_working_age, east_herts_working_age, + stevenage_working_age, stroud_working_age, warrington_working_age, dudley_working_age, @@ -143,7 +167,9 @@ def formula(benunit, period, parameters): england_pensioners_ctr.means_test.capital_limit, wales_ctr.means_test.capital_limit, scotland_ctr.means_test.capital_limit, + chesterfield_ctr.means_test.capital_limit, east_herts_ctr.means_test.capital_limit, + stevenage_ctr.means_test.capital_limit, stroud_ctr.means_test.capital_limit, warrington_ctr.means_test.capital_limit, dudley_ctr.means_test.capital_limit, From 767b6a1fb07ef571e47724c36b4650361b367719 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 23 Mar 2026 20:04:27 -0400 Subject: [PATCH 07/11] Add entitledto CTR comparison harness --- .../council_tax_reduction/README.md | 10 + scripts/entitledto_ctr_compare.py | 529 ++++++++++++++++++ 2 files changed, 539 insertions(+) create mode 100644 scripts/entitledto_ctr_compare.py diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md index 58ad66cc0..b06a0b0b1 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md @@ -15,6 +15,16 @@ The current implementation does not yet model: - Alternative maximum / second adult rebate cases. - Universal Credit-specific CTR adjustments beyond the standard income treatment used here. +There is also a requests-based entitledto comparison harness at `scripts/entitledto_ctr_compare.py`. It replays the calculator from a saved browser storage state and currently covers three single-adult claimant variants: + +- `single_jsa` +- `single_jsa_pip_standard` +- `single_pension_credit` + +It does not yet cover couples, children, or other-adult/non-dependant household shapes. entitledto's session handling is also inconsistent enough that rerunning with a fresh storage state is sometimes necessary. A typical single-scenario run is: + +`uv run --with requests --with beautifulsoup4 python scripts/entitledto_ctr_compare.py output/playwright/entitledto-live-state.json --postcode SG13 8EQ --band D --council-tax 1800 --scenario single_jsa` + ## Validation notes Spot checks against public calculators and scheme sources currently show: diff --git a/scripts/entitledto_ctr_compare.py b/scripts/entitledto_ctr_compare.py new file mode 100644 index 000000000..9e5702bfa --- /dev/null +++ b/scripts/entitledto_ctr_compare.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 + +import argparse +import json +import re +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional +from urllib.parse import urljoin + +import requests +from bs4 import BeautifulSoup + + +USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/146.0.0.0 Safari/537.36" +) + + +@dataclass(frozen=True) +class Scenario: + description: str + age_overrides: Dict[str, str] + benefits_overrides: Dict[str, str] + disability_overrides: Optional[Dict[str, str]] = None + net_income_overrides: Optional[Dict[str, str]] = None + housing_costs_overrides: Optional[Dict[str, str]] = None + + +SCENARIOS = { + "single_jsa": Scenario( + description="Single working-age claimant on income-based JSA.", + age_overrides={ + "Age": "35", + "Gender": "Male", + "ClientWorkStatus": "NotEmployed", + "WeekWorkHoursAmount": "0", + "WorkStatus": "False", + "ClientDisbens": "NotClaimed", + "ClientDisabledNotClaiming": "False", + "ClientCareForDisabled": "False", + }, + benefits_overrides={ + "ReceiveUniversalCredit": "False", + "ReceiveTransitionalElement": "False", + "IncomeBasedBenefit": "jobseekerallowanceincomebased", + "ReceivedManagedMigrationNotice": "False", + "ClientReceivesOtherBenefits": "False", + }, + net_income_overrides={ + "IsClientIncomeNonStatePensions": "False", + "IsFosteringAllowanceOption": "False", + "IncomeFromSavingsChk": "False", + "IsIncomeFromMaintenancePaymentsOption": "False", + "IsIncomeFromVoluntaryCharitablePaymentsOption": "False", + "OwnOtherProperty": "False", + "IsOtherSourcesIncome": "False", + }, + ), + "single_jsa_pip_standard": Scenario( + description="Single working-age claimant on income-based JSA with standard daily living PIP.", + age_overrides={ + "Age": "35", + "Gender": "Male", + "ClientWorkStatus": "NotEmployed", + "WeekWorkHoursAmount": "0", + "WorkStatus": "False", + "ClientDisbens": "CurrentlyClaiming", + "ClientDisabledNotClaiming": "False", + "ClientCareForDisabled": "False", + }, + disability_overrides={ + "PersonalIndependencePaymentDailyLiving.SelectedOption": "2", + "PersonalIndependencePaymentDailyLiving.Value": "73.9", + "PersonalIndependencePaymentDailyLiving.PaymentPeriod": "2", + }, + benefits_overrides={ + "ReceiveUniversalCredit": "False", + "ReceiveTransitionalElement": "False", + "IncomeBasedBenefit": "jobseekerallowanceincomebased", + "ReceivedManagedMigrationNotice": "False", + "ClientReceivesOtherBenefits": "False", + }, + net_income_overrides={ + "IsClientIncomeNonStatePensions": "False", + "IsFosteringAllowanceOption": "False", + "IncomeFromSavingsChk": "False", + "IsIncomeFromMaintenancePaymentsOption": "False", + "IsIncomeFromVoluntaryCharitablePaymentsOption": "False", + "OwnOtherProperty": "False", + "IsOtherSourcesIncome": "False", + }, + ), + "single_pension_credit": Scenario( + description="Single pension-age claimant with Pension Credit and State Pension.", + age_overrides={ + "Age": "70", + "DOB_Day": "1", + "DOB_Month": "1", + "DOB_Year": "1956", + "Gender": "Male", + "ClientWorkStatus": "NotEmployed", + "WeekWorkHoursAmount": "0", + "WorkStatus": "False", + "ClientDisbens": "NotClaimed", + "ClientDisabledNotClaiming": "False", + "ClientCareForDisabled": "False", + }, + benefits_overrides={ + "HasPensionCredit": "True", + "ClientRetirementPension.SelectedOption": "1", + "ClientRetirementPension.Value": "185.15", + "ClientRetirementPension.PaymentPeriod": "2", + "ClientReceivesOtherBenefits": "False", + }, + net_income_overrides={ + "IsClientIncomeNonStatePensions": "False", + "IsFosteringAllowanceOption": "False", + "IncomeFromSavingsChk": "False", + "IsIncomeFromMaintenancePaymentsOption": "False", + "IsIncomeFromVoluntaryCharitablePaymentsOption": "False", + "OwnOtherProperty": "False", + "IsOtherSourcesIncome": "False", + }, + ), +} + + +HOUSEHOLD_TEMPLATE = { + "CompanyName": "entitledto", + "HasSEISS": "False", + "HideNonDeps": "False", + "SiteId": "4", + "CalcYears": "22", + "HasPartner": "False", + "HouseholdChildrenNumber": "0", + "AnyoneElseLivesInYourHome": "No", + "ImmigrationControl": "True", + "ClientImmigrationStatus": "BritishOrIrish", + "CoronaVirusStatus": "None", + "ResCare": "False", + "VehiclesModel.DoYouOwnAVehicle": "False", + "VehiclesModel.NumberOfVehicles": "0", + "NavigationControls.SaveButtonIsPressed": "False", + "NavigationControls.ShowSaveButton": "True", + "NavigationControls.ShowNextButton": "True", +} + + +EMPTY_NET_INCOME = { + "IsClientIncomeNonStatePensions": "False", + "IsFosteringAllowanceOption": "False", + "IncomeFromSavingsChk": "False", + "IsIncomeFromMaintenancePaymentsOption": "False", + "IsIncomeFromVoluntaryCharitablePaymentsOption": "False", + "OwnOtherProperty": "False", + "IsOtherSourcesIncome": "False", +} + + +def load_session(storage_state_path: Path) -> requests.Session: + state = json.loads(storage_state_path.read_text()) + session = requests.Session() + for cookie in state.get("cookies", []): + domain = cookie.get("domain", "") + if "entitledto.co.uk" not in domain: + continue + session.cookies.set( + cookie["name"], + cookie["value"], + domain=domain, + path=cookie.get("path", "/"), + ) + session.headers.update( + { + "User-Agent": USER_AGENT, + "Accept-Language": "en-US,en;q=0.9", + } + ) + return session + + +def extract_primary_form(html: str, current_url: str) -> tuple[str, Dict[str, str]]: + soup = BeautifulSoup(html, "html.parser") + candidate = None + for form in soup.find_all("form"): + if "/benefits-calculator/" in (form.get("action") or ""): + candidate = form + break + if candidate is None: + raise RuntimeError(f"No entitledto calculator form found at {current_url}") + + action = urljoin(current_url, candidate.get("action", current_url)) + payload: Dict[str, str] = {} + radio_groups = set() + + for element in candidate.find_all(["input", "select", "textarea"]): + name = element.get("name") + if not name: + continue + tag = element.name + element_type = (element.get("type") or "").lower() + + if tag == "select": + selected = element.find("option", selected=True) or element.find("option") + payload[name] = selected.get("value", "") if selected else "" + continue + + if tag == "textarea": + payload[name] = element.text or "" + continue + + if element_type in {"submit", "button", "image", "file", "reset"}: + continue + + value = element.get("value", "") + data_value = element.get("data-value") + if ( + data_value is not None + and element_type == "hidden" + and ( + value.startswith("System.String") + or value.startswith("System.Collections.Generic") + ) + ): + value = data_value + + if element_type == "radio": + if name in radio_groups: + continue + checked = candidate.find("input", attrs={"name": name, "checked": True}) + if checked is not None: + payload[name] = checked.get("value", "") + radio_groups.add(name) + continue + + if element_type == "checkbox": + if element.has_attr("checked"): + payload[name] = value or "on" + continue + + payload[name] = value + + return action, payload + + +def follow_post( + session: requests.Session, action: str, payload: Dict[str, str] +) -> requests.Response: + response = session.post(action, data=payload, allow_redirects=False, timeout=30) + if response.is_redirect or response.is_permanent_redirect: + location = response.headers["location"] + return session.get(urljoin(action, location), timeout=30) + return response + + +def page_title(html: str) -> str: + soup = BeautifulSoup(html, "html.parser") + return soup.title.get_text(strip=True) if soup.title else "" + + +def bootstrap_single_adult( + session: requests.Session, + postcode: str, + housing_status: str, +) -> requests.Response: + response = None + action = None + payload = None + for _ in range(3): + response = session.get( + "https://www.entitledto.co.uk/benefits-calculator/", timeout=30 + ) + action, payload = extract_primary_form(response.text, response.url) + if "CalcIdent" in payload: + break + if response is None or action is None or payload is None or "CalcIdent" not in payload: + raise RuntimeError( + "entitledto did not return the calculator start page with a CalcIdent" + ) + payload.update( + { + "NationalHousingStatus": housing_status, + "Postcode": postcode, + } + ) + response = follow_post(session, action, payload) + household_payload = dict(HOUSEHOLD_TEMPLATE) + action, household_defaults = extract_primary_form(response.text, response.url) + household_payload["CalcIdent"] = household_defaults["CalcIdent"] + household_payload["StartTime"] = household_defaults["StartTime"] + return follow_post(session, action, household_payload) + + +def post_form_overrides( + session: requests.Session, + response: requests.Response, + overrides: Optional[Dict[str, str]] = None, +) -> requests.Response: + action, payload = extract_primary_form(response.text, response.url) + payload.update(overrides or {}) + return follow_post(session, action, payload) + + +def require_page(response: requests.Response, fragment: str) -> None: + if fragment not in response.url: + raise RuntimeError( + f"Expected entitledto page containing {fragment!r}, got {response.url!r}" + ) + + +def normalize_text(html: str) -> str: + return " ".join(BeautifulSoup(html, "html.parser").get_text(" ", strip=True).split()) + + +def extract_money(pattern: str, text: str) -> Optional[str]: + match = re.search(pattern, text) + if not match: + return None + return match.group(1).replace(" ", "") + + +def parse_results(html: str) -> Dict[str, Optional[str]]: + text = normalize_text(html) + payment_period_match = re.search( + r"Council Tax Support £\s*([0-9][0-9,\.\s]*)\s*/\s*(weekly|4 weeks|monthly|yearly)", + text, + re.IGNORECASE, + ) + bill_match = re.search( + r"full Council Tax bill of £\s*([0-9][0-9,\.\s]*)\s*per\s*(week|4 weeks|month|year)" + r"\s*will be reduced to £\s*([0-9][0-9,\.\s]*)\s*per\s*(week|4 weeks|month|year)", + text, + re.IGNORECASE, + ) + total_match = re.search( + r"Total benefits entitlement.*?£\s*([0-9][0-9,\.\s]*)\s*/\s*(weekly|4 weeks|monthly|yearly)", + text, + re.IGNORECASE, + ) + return { + "council_tax_support": payment_period_match.group(1).replace(" ", "") + if payment_period_match + else None, + "council_tax_support_period": payment_period_match.group(2) + if payment_period_match + else None, + "council_tax_bill_before": bill_match.group(1).replace(" ", "") + if bill_match + else None, + "council_tax_bill_before_period": bill_match.group(2) if bill_match else None, + "council_tax_bill_after": bill_match.group(3).replace(" ", "") + if bill_match + else None, + "council_tax_bill_after_period": bill_match.group(4) if bill_match else None, + "total_benefits": total_match.group(1).replace(" ", "") if total_match else None, + "total_benefits_period": total_match.group(2) if total_match else None, + } + + +def run_scenario( + session: requests.Session, + scenario_name: str, + postcode: str, + housing_status: str, + council_tax_band: str, + council_tax: str, + use_band_amount: bool, + save_html_dir: Optional[Path], +) -> Dict[str, Optional[str]]: + scenario = SCENARIOS[scenario_name] + response = bootstrap_single_adult(session, postcode, housing_status) + require_page(response, "/AgeDisabilityStatus") + response = post_form_overrides(session, response, scenario.age_overrides) + + if "/DisabilityBenefits" in response.url: + if scenario.disability_overrides is None: + raise RuntimeError( + f"{scenario_name} reached DisabilityBenefits without a disability payload" + ) + response = post_form_overrides(session, response, scenario.disability_overrides) + + require_page(response, "/BenefitsYouCurrentlyReceive") + response = post_form_overrides(session, response, scenario.benefits_overrides) + require_page(response, "/NetIncome") + response = post_form_overrides( + session, + response, + {**EMPTY_NET_INCOME, **(scenario.net_income_overrides or {})}, + ) + require_page(response, "/HousingCosts") + response = post_form_overrides(session, response, scenario.housing_costs_overrides) + require_page(response, "/CouncilTax") + response = post_form_overrides( + session, + response, + { + "CouncilTaxBand": council_tax_band, + "EligibleDisabilityReduction": "False", + "DiscountsApplicable": "0", + "AmountIsCorrect": "True" if use_band_amount else "False", + "CouncilTax": council_tax, + "CouncilTaxPeriod": "0", + }, + ) + + if "/Results/" not in response.url: + raise RuntimeError( + f"{scenario_name} did not reach entitledto results; stopped at {response.url}" + ) + + if save_html_dir is not None: + save_html_dir.mkdir(parents=True, exist_ok=True) + output_path = save_html_dir / f"{scenario_name}.html" + output_path.write_text(response.text) + + result = { + "scenario": scenario_name, + "description": scenario.description, + "postcode": postcode, + "housing_status": housing_status, + "council_tax_band": council_tax_band, + "council_tax": council_tax, + "results_url": response.url, + "results_title": page_title(response.text), + } + result.update(parse_results(response.text)) + return result + + +def run_scenario_with_retries( + storage_state_path: Path, + scenario_name: str, + postcode: str, + housing_status: str, + council_tax_band: str, + council_tax: str, + use_band_amount: bool, + save_html_dir: Optional[Path], + attempts: int, +) -> Dict[str, Optional[str]]: + last_error: Optional[Exception] = None + for attempt in range(1, attempts + 1): + try: + return run_scenario( + session=load_session(storage_state_path), + scenario_name=scenario_name, + postcode=postcode, + housing_status=housing_status, + council_tax_band=council_tax_band, + council_tax=council_tax, + use_band_amount=use_band_amount, + save_html_dir=save_html_dir, + ) + except Exception as error: + last_error = error + if attempt < attempts: + time.sleep(1) + assert last_error is not None + raise last_error + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("storage_state", type=Path) + parser.add_argument("--postcode", required=True) + parser.add_argument("--housing-status", default="MortgageOrOwned") + parser.add_argument("--band", default="D") + parser.add_argument("--council-tax", default="1800") + parser.add_argument( + "--use-band-amount", + action="store_true", + help="Use entitledto's band-derived bill instead of the provided annual bill.", + ) + parser.add_argument( + "--scenario", + action="append", + choices=[*SCENARIOS.keys(), "all"], + default=["single_jsa"], + help="Scenario to run. Repeatable. Use 'all' to run every built-in scenario.", + ) + parser.add_argument("--save-html-dir", type=Path) + parser.add_argument("--attempts", type=int, default=5) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + scenario_names = args.scenario + if "all" in scenario_names: + scenario_names = list(SCENARIOS.keys()) + else: + # Preserve order while removing accidental duplicates. + scenario_names = list(dict.fromkeys(scenario_names)) + + try: + results = [ + run_scenario_with_retries( + storage_state_path=args.storage_state, + scenario_name=scenario_name, + postcode=args.postcode, + housing_status=args.housing_status, + council_tax_band=args.band, + council_tax=args.council_tax, + use_band_amount=args.use_band_amount, + save_html_dir=args.save_html_dir, + attempts=args.attempts, + ) + for scenario_name in scenario_names + ] + except Exception as error: + print( + f"error: {error}\n" + "If entitledto returned Cloudflare again, refresh the storage state first " + "with `playwright-cli state-save` or another browser-derived cookie export.", + file=sys.stderr, + ) + return 1 + + print(json.dumps(results, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From afa19aac4b944d396c16bc3a790a16795f8d2d7d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 23 Mar 2026 20:30:08 -0400 Subject: [PATCH 08/11] Document additional entitledto household checks --- .../gov/local_authorities/council_tax_reduction/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md index b06a0b0b1..1059fb761 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md @@ -21,7 +21,7 @@ There is also a requests-based entitledto comparison harness at `scripts/entitle - `single_jsa_pip_standard` - `single_pension_credit` -It does not yet cover couples, children, or other-adult/non-dependant household shapes. entitledto's session handling is also inconsistent enough that rerunning with a fresh storage state is sometimes necessary. A typical single-scenario run is: +It does not yet cover couples, children, or other-adult/non-dependant household shapes. entitledto's session handling is also inconsistent enough that rerunning with a fresh storage state is sometimes necessary, and fresh anonymous calculator starts can hit entitledto's day limit. A typical single-scenario run is: `uv run --with requests --with beautifulsoup4 python scripts/entitledto_ctr_compare.py output/playwright/entitledto-live-state.json --postcode SG13 8EQ --band D --council-tax 1800 --scenario single_jsa` @@ -32,11 +32,13 @@ Spot checks against public calculators and scheme sources currently show: - Stroud (`GL5 4UB`, area `Stroud`, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns full Council Tax Support on an annual liability of `GBP 1,866.47`. PolicyEngine UK returns `council_tax_reduction = GBP 1,866.47`. - Dudley (`DY1 1HF`, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns `GBP 15.64` per week of Council Tax Support, leaving `GBP 10.43` per week to pay. PolicyEngine UK returns `council_tax_reduction = GBP 543.62` per year and `council_tax_less_benefit = GBP 815.43` per year, matching Dudley Council's published 2025/26 scheme text and scheme PDF rather than the entitledto output. - East Hertfordshire (`SG13 8EQ`, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA, annual liability `GBP 1,800`): entitledto returns `GBP 31.59` per week of Council Tax Support and `GBP 2.93` per week left to pay. PolicyEngine UK applies the same `91.5%` maximum award rule, giving `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153` per year. +- East Hertfordshire (`SG13 8EQ`, band `D`, single working-age lone parent, one child aged `5`, no savings, income-based JSA, annual liability `GBP 1,800`): a headed browser run through entitledto returns total benefits of `GBP 367.64` per week, made up of `GBP 310.00` income-based Jobseeker's Allowance, `GBP 31.59` Council Tax Support, and `GBP 26.05` Child Benefit. The calculator shows a weekly Council Tax bill of `GBP 34.52` reduced to `GBP 2.93`. +- East Hertfordshire (`SG13 8EQ`, band `D`, two working-age adults, no children, no savings, income-based JSA, annual liability `GBP 1,800`): a headed browser run through entitledto returns total benefits of `GBP 237.89` per week, made up of `GBP 206.30` income-based Jobseeker's Allowance and `GBP 31.59` Council Tax Support. The calculator again shows a weekly Council Tax bill of `GBP 34.52` reduced to `GBP 2.93`. Because this check reused an existing entitledto calculation ID to get around the anonymous daily cap, the Council Tax page initially carried forward a stale `25%` single-adult discount and had to be corrected back to `none` before the result was trusted. - Stevenage (working-age, band `D`, annual liability `GBP 1,800`, no children, no savings): the council's published scheme says working-age claimants receive `91.5%` of net liability, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. - Chesterfield (working-age, band `D`, annual liability `GBP 1,800`, no children, no savings): the council's published scheme says working-age claimants receive `91.5%` of net liability, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. - Warrington (`WA1 1UH`, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns `GBP 26.69` per week of Council Tax Support and `GBP 2.48` per week left to pay on a displayed bill of `GBP 29.17` per week, which is a `91.5%` maximum award on the displayed weekly bill. PolicyEngine UK applies the same rule structure. -For the East Hertfordshire and Warrington calculator checks, entitledto's annual tab appears to multiply rounded weekly outputs by `52`, so the weekly figures are the closest comparison point. +For the East Hertfordshire and Warrington calculator checks, entitledto's annual tab appears to multiply rounded weekly outputs by `52`, so the weekly figures are the closest comparison point. Additional browser-only probing suggests that reusing an existing entitledto calculation ID is good enough for lone-parent and couple spot checks, but not yet good enough for other-adult/non-dependant cases: the later pages can retain stale branch state, so those results should not be treated as validated until the flow is replayed from a fresh entitledto session. Dudley references: From 7eeabed8c0b2f71d90cab8d8e9fd62c5c14a3e19 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 23 Mar 2026 23:54:12 -0400 Subject: [PATCH 09/11] Handle East Herts class D CTR awards --- .../council_tax_reduction.yaml | 32 +++++++++++++++++++ .../council_tax_reduction/README.md | 4 +-- ...simulated_council_tax_reduction_benunit.py | 9 ++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml index d1a6757be..6f051edfa 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml @@ -221,6 +221,38 @@ council_tax_reduction: 1647 council_tax_less_benefit: 153 +- name: East Hertfordshire lone parent on income-based JSA keeps the full class D award + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + jsa_income_reported: 1 + child: + age: 5 + benunits: + benunit: + members: [claimant, child] + claims_all_entitled_benefits: true + would_claim_uc: false + is_single_person: false + is_couple: false + is_lone_parent: true + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant, child] + country: ENGLAND + local_authority: EAST_HERTFORDSHIRE + council_tax_band: D + council_tax: 1800 + savings: 0 + output: + council_tax_reduction: 1647 + council_tax_less_benefit: 153 + - name: Stevenage working-age claimant pays the 8.5 percent minimum contribution period: 2025 absolute_error_margin: 0.5 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md index 1059fb761..d9399a3ed 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md @@ -32,8 +32,8 @@ Spot checks against public calculators and scheme sources currently show: - Stroud (`GL5 4UB`, area `Stroud`, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns full Council Tax Support on an annual liability of `GBP 1,866.47`. PolicyEngine UK returns `council_tax_reduction = GBP 1,866.47`. - Dudley (`DY1 1HF`, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns `GBP 15.64` per week of Council Tax Support, leaving `GBP 10.43` per week to pay. PolicyEngine UK returns `council_tax_reduction = GBP 543.62` per year and `council_tax_less_benefit = GBP 815.43` per year, matching Dudley Council's published 2025/26 scheme text and scheme PDF rather than the entitledto output. - East Hertfordshire (`SG13 8EQ`, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA, annual liability `GBP 1,800`): entitledto returns `GBP 31.59` per week of Council Tax Support and `GBP 2.93` per week left to pay. PolicyEngine UK applies the same `91.5%` maximum award rule, giving `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153` per year. -- East Hertfordshire (`SG13 8EQ`, band `D`, single working-age lone parent, one child aged `5`, no savings, income-based JSA, annual liability `GBP 1,800`): a headed browser run through entitledto returns total benefits of `GBP 367.64` per week, made up of `GBP 310.00` income-based Jobseeker's Allowance, `GBP 31.59` Council Tax Support, and `GBP 26.05` Child Benefit. The calculator shows a weekly Council Tax bill of `GBP 34.52` reduced to `GBP 2.93`. -- East Hertfordshire (`SG13 8EQ`, band `D`, two working-age adults, no children, no savings, income-based JSA, annual liability `GBP 1,800`): a headed browser run through entitledto returns total benefits of `GBP 237.89` per week, made up of `GBP 206.30` income-based Jobseeker's Allowance and `GBP 31.59` Council Tax Support. The calculator again shows a weekly Council Tax bill of `GBP 34.52` reduced to `GBP 2.93`. Because this check reused an existing entitledto calculation ID to get around the anonymous daily cap, the Council Tax page initially carried forward a stale `25%` single-adult discount and had to be corrected back to `none` before the result was trusted. +- East Hertfordshire (`SG13 8EQ`, band `D`, single working-age lone parent, one child aged `5`, no savings, income-based JSA, annual liability `GBP 1,800`): a headed browser run through entitledto returns total benefits of `GBP 367.64` per week, made up of `GBP 310.00` income-based Jobseeker's Allowance, `GBP 31.59` Council Tax Support, and `GBP 26.05` Child Benefit. The calculator shows a weekly Council Tax bill of `GBP 34.52` reduced to `GBP 2.93`. PolicyEngine UK now keeps this household on the East Herts class D maximum award, returning `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. +- East Hertfordshire (`SG13 8EQ`, band `D`, two working-age adults, no children, no savings, income-based JSA, annual liability `GBP 1,800`): a headed browser run through entitledto returns total benefits of `GBP 237.89` per week, made up of `GBP 206.30` income-based Jobseeker's Allowance and `GBP 31.59` Council Tax Support. The calculator again shows a weekly Council Tax bill of `GBP 34.52` reduced to `GBP 2.93`. PolicyEngine UK returns the same annual CTR position, `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. Because this check reused an existing entitledto calculation ID to get around the anonymous daily cap, the Council Tax page initially carried forward a stale `25%` single-adult discount and had to be corrected back to `none` before the result was trusted. - Stevenage (working-age, band `D`, annual liability `GBP 1,800`, no children, no savings): the council's published scheme says working-age claimants receive `91.5%` of net liability, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. - Chesterfield (working-age, band `D`, annual liability `GBP 1,800`, no children, no savings): the council's published scheme says working-age claimants receive `91.5%` of net liability, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. - Warrington (`WA1 1UH`, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns `GBP 26.69` per week of Council Tax Support and `GBP 2.48` per week left to pay on a displayed bill of `GBP 29.17` per week, which is a `91.5%` maximum award on the displayed weekly bill. PolicyEngine UK applies the same rule structure. diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index 5a33aa078..2a760c73e 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -50,6 +50,9 @@ def formula(benunit, period, parameters): "council_tax_reduction_income_below_applicable_amount", period, ) + east_herts_relevant_benefit = ( + add(benunit, period, ["income_support", "jsa_income", "esa_income"]) > 0 + ) england_pensioners = is_england_pensioner_scheme(country, has_pensioner) wales = is_wales_scheme(country) @@ -86,6 +89,9 @@ def formula(benunit, period, parameters): ) warrington_band_a = council_tax_band == CouncilTaxBand.A warrington_class_d = warrington_working_age & income_below_applicable_amount + east_herts_class_d = east_herts_working_age & ( + income_below_applicable_amount | east_herts_relevant_benefit + ) warrington_max_support = select( [ warrington_class_d & warrington_band_a, @@ -177,6 +183,9 @@ def formula(benunit, period, parameters): default=0.0, ) excess_income = max_(0, applicable_income - applicable_amount) + # East Herts class D claimants keep the full maximum award if they are + # passported by income-based JSA, income support, or income-related ESA. + excess_income = where(east_herts_class_d, 0, excess_income) preliminary_award = max_( 0, liability * max_support From cf47308c41cc958ab71cc7ab8066ac707690aa3f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 24 Mar 2026 07:10:50 -0400 Subject: [PATCH 10/11] Document PiP CTR validation sweep --- .../local_authorities/council_tax_reduction/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md index d9399a3ed..66b9be46f 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/README.md @@ -25,6 +25,10 @@ It does not yet cover couples, children, or other-adult/non-dependant household `uv run --with requests --with beautifulsoup4 python scripts/entitledto_ctr_compare.py output/playwright/entitledto-live-state.json --postcode SG13 8EQ --band D --council-tax 1800 --scenario single_jsa` +There is also a requests-based Turn2us probe at `scripts/turn2us_ctr_compare.py`. It currently replays the public Turn2us calculator for a single working-age JSA claimant and records the benefits that appear on the final results page for the supplied postcode. A typical multi-authority run is: + +`uv run --with requests python scripts/turn2us_ctr_compare.py "SG13 8EQ" "WA1 1UH" "DY1 1HF" "GL5 4UB"` + ## Validation notes Spot checks against public calculators and scheme sources currently show: @@ -37,9 +41,16 @@ Spot checks against public calculators and scheme sources currently show: - Stevenage (working-age, band `D`, annual liability `GBP 1,800`, no children, no savings): the council's published scheme says working-age claimants receive `91.5%` of net liability, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. - Chesterfield (working-age, band `D`, annual liability `GBP 1,800`, no children, no savings): the council's published scheme says working-age claimants receive `91.5%` of net liability, so PolicyEngine UK returns `council_tax_reduction = GBP 1,647` and `council_tax_less_benefit = GBP 153`. - Warrington (`WA1 1UH`, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA): entitledto returns `GBP 26.69` per week of Council Tax Support and `GBP 2.48` per week left to pay on a displayed bill of `GBP 29.17` per week, which is a `91.5%` maximum award on the displayed weekly bill. PolicyEngine UK applies the same rule structure. +- Policy in Practice Better Off Calculator (`SG13 8EQ`, East Hertfordshire, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA, monthly council tax liability `GBP 150`): the public calculator resolves East Hertfordshire from postcode and returns `GBP 137.25` per month of Council Tax Support. That is exactly a `91.5%` award on the entered liability, matching the scheme structure and the PolicyEngine UK rule. +- Policy in Practice Better Off Calculator (`DY1 1HF`, Dudley, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA, monthly council tax liability `GBP 113.25` with single-person discount): the public calculator resolves Dudley from postcode and returns `GBP 45.30` per month of Council Tax Support. That is exactly a `40%` award on the entered liability, matching Dudley's published scheme structure and the PolicyEngine UK rule. +- Policy in Practice Better Off Calculator (`WA1 1UH`, Warrington, band `C`, single working-age owner-occupier, no children, no savings, income-based JSA, monthly council tax liability `GBP 126.74` with single-person discount): the public calculator resolves Warrington from postcode and returns `GBP 95.06` per month of Council Tax Support. That is a `75%` award on the entered liability, which does not match Warrington's published 2025/26 Class D passported-JSA scheme, entitledto's `91.5%` result for the same scenario, or the current PolicyEngine UK encoding. +- Policy in Practice Better Off Calculator (`GL5 4UB`, Stroud, band `D`, single working-age owner-occupier, no children, no savings, income-based JSA, monthly council tax liability `GBP 148.09` with single-person discount): the public calculator resolves Stroud from postcode and returns `GBP 148.09` per month of Council Tax Support. That is full support on the entered liability, matching entitledto's behavior and the published Stroud scheme summary. +- Turn2us public calculator checks on the same four single working-age owner-occupier income-based JSA cases in East Hertfordshire, Warrington, Dudley, and Stroud all reached the final results page, but returned the same `Income-based Jobseekers Allowance` and `Universal Credit` lines for all four authorities and no CTR output. In these cases, Turn2us was not acting as an authority-specific CTR comparator. For the East Hertfordshire and Warrington calculator checks, entitledto's annual tab appears to multiply rounded weekly outputs by `52`, so the weekly figures are the closest comparison point. Additional browser-only probing suggests that reusing an existing entitledto calculation ID is good enough for lone-parent and couple spot checks, but not yet good enough for other-adult/non-dependant cases: the later pages can retain stale branch state, so those results should not be treated as validated until the flow is replayed from a fresh entitledto session. +The public Policy in Practice calculator is testable through the browser, but not through a simple documented one-shot API. The frontend is a token-gated SPA on `betteroffcalculator.co.uk` backed by `api.betteroffcalculator.co.uk`; raw ad hoc `POST /api` requests return default baseline results unless the full calculator state has already been established in the session. + Dudley references: - https://www.dudley.gov.uk/residents/benefits/council-tax-reduction-scheme/ From 303beb43ddc42351b82cb3bd463963c4608aac8a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 24 Mar 2026 08:42:16 -0400 Subject: [PATCH 11/11] Add CTR comparison harnesses and JSA regression fixes --- .../council_tax_reduction.yaml | 159 +++++++++ ...ax_reduction_maximum_eligible_liability.py | 8 + ...reduction_relevant_income_based_benefit.py | 13 + ...simulated_council_tax_reduction_benunit.py | 30 +- scripts/entitledto_ctr_compare.py | 210 ++++++++++- scripts/policyengine_ctr_compare.py | 279 +++++++++++++++ scripts/turn2us_ctr_compare.py | 329 ++++++++++++++++++ 7 files changed, 1012 insertions(+), 16 deletions(-) create mode 100644 policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py create mode 100644 scripts/policyengine_ctr_compare.py create mode 100644 scripts/turn2us_ctr_compare.py diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml index 6f051edfa..93ddadc1f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml @@ -56,6 +56,70 @@ council_tax_reduction: 1866.47 council_tax_less_benefit: 0 +- name: Stroud claimant on income-based JSA keeps full support on the billed liability + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + jsa_income_reported: 4_786.6 + council_tax_benefit_reported: 1 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: false + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: STROUD + council_tax_band: D + council_tax: 1777.08 + savings: 0 + output: + council_tax_reduction: 1777.08 + council_tax_less_benefit: 0 + +- name: Stroud lone parent on income-based JSA keeps full support on the billed liability + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + jsa_income_reported: 4_786.6 + council_tax_benefit_reported: 1 + child: + age: 5 + benunits: + benunit: + members: [claimant, child] + claims_all_entitled_benefits: false + would_claim_uc: false + is_single_person: false + is_couple: false + is_lone_parent: true + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant, child] + country: ENGLAND + local_authority: STROUD + council_tax_band: D + council_tax: 1777.08 + savings: 0 + output: + council_tax_reduction: 1777.08 + council_tax_less_benefit: 0 + - name: Dudley working-age claimant faces 60 percent minimum payment and band C cap period: 2025 absolute_error_margin: 0.5 @@ -153,6 +217,37 @@ council_tax_reduction: 543.62 council_tax_less_benefit: 815.43 +- name: Dudley single claimant on income-based JSA keeps the full published 40 percent maximum award + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + jsa_income_reported: 4_786.6 + council_tax_benefit_reported: 1 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: false + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: DUDLEY + council_tax_band: C + council_tax: 1359.05 + savings: 0 + output: + council_tax_reduction: 543.62 + council_tax_less_benefit: 815.43 + - name: Dudley waives the flat non-dependant deduction for PIP daily living claimants period: 2025 absolute_error_margin: 0.5 @@ -460,6 +555,70 @@ council_tax_reduction: 1647 council_tax_less_benefit: 153 +- name: Warrington single claimant on income-based JSA stays in class D and keeps 91.5 percent support + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + jsa_income_reported: 4_786.6 + council_tax_benefit_reported: 1 + benunits: + benunit: + members: [claimant] + claims_all_entitled_benefits: false + would_claim_uc: false + is_single_person: true + is_couple: false + is_lone_parent: false + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant] + country: ENGLAND + local_authority: WARRINGTON + council_tax_band: C + council_tax: 1520.88 + savings: 0 + output: + council_tax_reduction: 1391.61 + council_tax_less_benefit: 129.27 + +- name: Warrington lone parent on income-based JSA stays in class D and keeps 91.5 percent support + period: 2025 + absolute_error_margin: 0.5 + input: + people: + claimant: + age: 35 + jsa_income_reported: 4_786.6 + council_tax_benefit_reported: 1 + child: + age: 5 + benunits: + benunit: + members: [claimant, child] + claims_all_entitled_benefits: false + would_claim_uc: false + is_single_person: false + is_couple: false + is_lone_parent: true + eldest_adult_age: 35 + benefits_premiums: 0 + households: + household: + members: [claimant, child] + country: ENGLAND + local_authority: WARRINGTON + council_tax_band: C + council_tax: 1520.88 + savings: 0 + output: + council_tax_reduction: 1391.61 + council_tax_less_benefit: 129.27 + - name: Warrington class E working-age claimant above Band A is capped to Band A liability period: 2025 absolute_error_margin: 0.5 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py index 6ca9ee9cf..970d5e8f6 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_maximum_eligible_liability.py @@ -39,6 +39,13 @@ def formula(household, period, parameters): period, ) ) + claimant_relevant_income_based_benefit = household.any( + claimant_benunit + & person.benunit( + "council_tax_reduction_relevant_income_based_benefit", + period, + ) + ) band_ratio = english_council_tax_band_ratio( council_tax_band, @@ -62,6 +69,7 @@ def formula(household, period, parameters): warrington_capped = ( warrington_working_age & ~claimant_income_below_applicable_amount + & ~claimant_relevant_income_based_benefit & (band_ratio > warrington_cap_band_ratio) ) capped_liability = where(dudley_capped, band_c_liability, council_tax) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py new file mode 100644 index 000000000..5efbe925f --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_relevant_income_based_benefit(Variable): + value_type = bool + entity = BenUnit + label = "CTR claimant has an income-based passporting benefit" + definition_period = YEAR + + def formula(benunit, period, parameters): + return ( + add(benunit, period, ["income_support", "jsa_income", "esa_income"]) > 0 + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py index 2a760c73e..33e621153 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/simulated_council_tax_reduction_benunit.py @@ -50,8 +50,9 @@ def formula(benunit, period, parameters): "council_tax_reduction_income_below_applicable_amount", period, ) - east_herts_relevant_benefit = ( - add(benunit, period, ["income_support", "jsa_income", "esa_income"]) > 0 + relevant_income_based_benefit = benunit( + "council_tax_reduction_relevant_income_based_benefit", + period, ) england_pensioners = is_england_pensioner_scheme(country, has_pensioner) @@ -88,9 +89,22 @@ def formula(benunit, period, parameters): has_pensioner, ) warrington_band_a = council_tax_band == CouncilTaxBand.A - warrington_class_d = warrington_working_age & income_below_applicable_amount + warrants_like_legacy_scheme = ( + chesterfield_working_age + | east_herts_working_age + | stevenage_working_age + | stroud_working_age + | warrington_working_age + | dudley_working_age + ) + legacy_passported_claimant = ( + warrants_like_legacy_scheme & relevant_income_based_benefit + ) + warrington_class_d = warrington_working_age & ( + income_below_applicable_amount | relevant_income_based_benefit + ) east_herts_class_d = east_herts_working_age & ( - income_below_applicable_amount | east_herts_relevant_benefit + income_below_applicable_amount | relevant_income_based_benefit ) warrington_max_support = select( [ @@ -183,9 +197,11 @@ def formula(benunit, period, parameters): default=0.0, ) excess_income = max_(0, applicable_income - applicable_amount) - # East Herts class D claimants keep the full maximum award if they are - # passported by income-based JSA, income support, or income-related ESA. - excess_income = where(east_herts_class_d, 0, excess_income) + # The currently encoded English working-age schemes follow the legacy + # CTB-style treatment of income-based JSA, income support, and + # income-related ESA claimants. Those cases keep the maximum local + # award instead of entering the taper. + excess_income = where(legacy_passported_claimant, 0, excess_income) preliminary_award = max_( 0, liability * max_support diff --git a/scripts/entitledto_ctr_compare.py b/scripts/entitledto_ctr_compare.py index 9e5702bfa..da086bd5b 100644 --- a/scripts/entitledto_ctr_compare.py +++ b/scripts/entitledto_ctr_compare.py @@ -7,7 +7,7 @@ import time from dataclasses import dataclass from pathlib import Path -from typing import Dict, Optional +from typing import Callable, Dict, Optional, Sequence from urllib.parse import urljoin import requests @@ -24,8 +24,11 @@ @dataclass(frozen=True) class Scenario: description: str + household_overrides: Optional[Dict[str, str]] age_overrides: Dict[str, str] benefits_overrides: Dict[str, str] + partner_age_overrides: Optional[Dict[str, str]] = None + child_overrides: Optional[Sequence[Dict[str, str]]] = None disability_overrides: Optional[Dict[str, str]] = None net_income_overrides: Optional[Dict[str, str]] = None housing_costs_overrides: Optional[Dict[str, str]] = None @@ -34,6 +37,7 @@ class Scenario: SCENARIOS = { "single_jsa": Scenario( description="Single working-age claimant on income-based JSA.", + household_overrides=None, age_overrides={ "Age": "35", "Gender": "Male", @@ -63,6 +67,7 @@ class Scenario: ), "single_jsa_pip_standard": Scenario( description="Single working-age claimant on income-based JSA with standard daily living PIP.", + household_overrides=None, age_overrides={ "Age": "35", "Gender": "Male", @@ -97,6 +102,7 @@ class Scenario: ), "single_pension_credit": Scenario( description="Single pension-age claimant with Pension Credit and State Pension.", + household_overrides=None, age_overrides={ "Age": "70", "DOB_Day": "1", @@ -127,6 +133,93 @@ class Scenario: "IsOtherSourcesIncome": "False", }, ), + "couple_jsa": Scenario( + description="Couple with no children on income-based JSA.", + household_overrides={ + "HasPartner": "True", + }, + age_overrides={ + "Age": "35", + "Gender": "Male", + "ClientWorkStatus": "NotEmployed", + "WeekWorkHoursAmount": "0", + "WorkStatus": "False", + "ClientDisbens": "NotClaimed", + "ClientDisabledNotClaiming": "False", + "ClientCareForDisabled": "False", + }, + partner_age_overrides={ + "Age": "35", + "Gender": "Female", + "ClientWorkStatus": "NotEmployed", + "WeekWorkHoursAmount": "0", + "WorkStatus": "False", + "ClientDisbens": "NotClaimed", + "ClientDisabledNotClaiming": "False", + "ClientCareForDisabled": "False", + }, + benefits_overrides={ + "ReceiveUniversalCredit": "False", + "ReceiveTransitionalElement": "False", + "IncomeBasedBenefit": "jobseekerallowanceincomebased", + "ReceivedManagedMigrationNotice": "False", + "ClientReceivesOtherBenefits": "False", + }, + net_income_overrides={ + "IsClientIncomeNonStatePensions": "False", + "IsFosteringAllowanceOption": "False", + "IncomeFromSavingsChk": "False", + "IsIncomeFromMaintenancePaymentsOption": "False", + "IsIncomeFromVoluntaryCharitablePaymentsOption": "False", + "OwnOtherProperty": "False", + "IsOtherSourcesIncome": "False", + }, + ), + "lone_parent_one_child_jsa": Scenario( + description="Lone parent with one child on income-based JSA.", + household_overrides={ + "HouseholdChildrenNumber": "1", + }, + age_overrides={ + "Age": "35", + "Gender": "Male", + "ClientWorkStatus": "NotEmployed", + "WeekWorkHoursAmount": "0", + "WorkStatus": "False", + "ClientDisbens": "NotClaimed", + "ClientDisabledNotClaiming": "False", + "ClientCareForDisabled": "False", + }, + child_overrides=[ + { + "Age": "5", + "DOB_Day": "1", + "DOB_Month": "1", + "DOB_Year": "2021", + "PayForChildcare": "False", + "ChildcarePeriod": "2", + "ChildcareAmount": "0", + "IsDisabledPerson": "False", + "IsDisabledNotClaiming": "False", + } + ], + benefits_overrides={ + "ReceiveUniversalCredit": "False", + "ReceiveTransitionalElement": "False", + "IncomeBasedBenefit": "jobseekerallowanceincomebased", + "ReceivedManagedMigrationNotice": "False", + "ClientReceivesOtherBenefits": "False", + }, + net_income_overrides={ + "IsClientIncomeNonStatePensions": "False", + "IsFosteringAllowanceOption": "False", + "IncomeFromSavingsChk": "False", + "IsIncomeFromMaintenancePaymentsOption": "False", + "IsIncomeFromVoluntaryCharitablePaymentsOption": "False", + "OwnOtherProperty": "False", + "IsOtherSourcesIncome": "False", + }, + ), } @@ -184,6 +277,17 @@ def load_session(storage_state_path: Path) -> requests.Session: return session +def find_resume_cid(session: requests.Session) -> Optional[str]: + for cookie in session.cookies: + match = re.match( + r"f_/benefits-calculator/Intro/Home\?cid([0-9a-f-]{36})", + cookie.name, + ) + if match: + return match.group(1) + return None + + def extract_primary_form(html: str, current_url: str) -> tuple[str, Dict[str, str]]: soup = BeautifulSoup(html, "html.parser") candidate = None @@ -263,21 +367,31 @@ def page_title(html: str) -> str: return soup.title.get_text(strip=True) if soup.title else "" -def bootstrap_single_adult( +def bootstrap_calculation( session: requests.Session, postcode: str, housing_status: str, + household_overrides: Optional[Dict[str, str]] = None, ) -> requests.Response: response = None action = None payload = None - for _ in range(3): + resume_cid = find_resume_cid(session) + if resume_cid is not None: + response = session.get( + f"https://www.entitledto.co.uk/benefits-calculator/Intro/Home?cid={resume_cid}", + timeout=30, + ) + else: response = session.get( - "https://www.entitledto.co.uk/benefits-calculator/", timeout=30 + "https://www.entitledto.co.uk/benefits-calculator/", + timeout=30, ) + for _ in range(5): action, payload = extract_primary_form(response.text, response.url) if "CalcIdent" in payload: break + response = follow_post(session, action, payload) if response is None or action is None or payload is None or "CalcIdent" not in payload: raise RuntimeError( "entitledto did not return the calculator start page with a CalcIdent" @@ -293,6 +407,7 @@ def bootstrap_single_adult( action, household_defaults = extract_primary_form(response.text, response.url) household_payload["CalcIdent"] = household_defaults["CalcIdent"] household_payload["StartTime"] = household_defaults["StartTime"] + household_payload.update(household_overrides or {}) return follow_post(session, action, household_payload) @@ -306,6 +421,20 @@ def post_form_overrides( return follow_post(session, action, payload) +def post_until( + session: requests.Session, + response: requests.Response, + overrides: Optional[Dict[str, str]], + predicate: Callable[[requests.Response], bool], + attempts: int = 3, +) -> requests.Response: + for _ in range(attempts): + response = post_form_overrides(session, response, overrides) + if predicate(response): + return response + return response + + def require_page(response: requests.Response, fragment: str) -> None: if fragment not in response.url: raise RuntimeError( @@ -373,19 +502,77 @@ def run_scenario( save_html_dir: Optional[Path], ) -> Dict[str, Optional[str]]: scenario = SCENARIOS[scenario_name] - response = bootstrap_single_adult(session, postcode, housing_status) + response = bootstrap_calculation( + session, + postcode, + housing_status, + household_overrides=scenario.household_overrides, + ) require_page(response, "/AgeDisabilityStatus") - response = post_form_overrides(session, response, scenario.age_overrides) + if scenario.partner_age_overrides is not None: + response = post_until( + session, + response, + scenario.age_overrides, + lambda resp: "client=Partner" in resp.url, + ) + elif scenario.child_overrides is not None: + response = post_until( + session, + response, + scenario.age_overrides, + lambda resp: "/Children" in resp.url, + ) + else: + response = post_until( + session, + response, + scenario.age_overrides, + lambda resp: ( + "/BenefitsYouCurrentlyReceive" in resp.url + or "/DisabilityBenefits" in resp.url + ), + ) + + if scenario.partner_age_overrides is not None: + require_page(response, "/AgeDisabilityStatus") + response = post_until( + session, + response, + scenario.partner_age_overrides, + lambda resp: "/BenefitsYouCurrentlyReceive" in resp.url, + ) + + if scenario.child_overrides is not None: + for child_overrides in scenario.child_overrides: + require_page(response, "/Children") + response = post_until( + session, + response, + child_overrides, + lambda resp: "/BenefitsYouCurrentlyReceive" in resp.url + or "/Children" in resp.url, + ) if "/DisabilityBenefits" in response.url: if scenario.disability_overrides is None: raise RuntimeError( f"{scenario_name} reached DisabilityBenefits without a disability payload" ) - response = post_form_overrides(session, response, scenario.disability_overrides) + response = post_until( + session, + response, + scenario.disability_overrides, + lambda resp: "/BenefitsYouCurrentlyReceive" in resp.url, + ) require_page(response, "/BenefitsYouCurrentlyReceive") - response = post_form_overrides(session, response, scenario.benefits_overrides) + response = post_until( + session, + response, + scenario.benefits_overrides, + lambda resp: "/NetIncome" in resp.url, + ) require_page(response, "/NetIncome") response = post_form_overrides( session, @@ -393,7 +580,12 @@ def run_scenario( {**EMPTY_NET_INCOME, **(scenario.net_income_overrides or {})}, ) require_page(response, "/HousingCosts") - response = post_form_overrides(session, response, scenario.housing_costs_overrides) + response = post_until( + session, + response, + scenario.housing_costs_overrides, + lambda resp: "/CouncilTax" in resp.url, + ) require_page(response, "/CouncilTax") response = post_form_overrides( session, diff --git a/scripts/policyengine_ctr_compare.py b/scripts/policyengine_ctr_compare.py new file mode 100644 index 000000000..4cb10db58 --- /dev/null +++ b/scripts/policyengine_ctr_compare.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 + +import argparse +import json +import sys +from dataclasses import dataclass +from typing import Callable + +from policyengine_uk import Simulation + + +@dataclass(frozen=True) +class CouncilConfig: + label: str + local_authority: str + council_tax_band: str + council_tax: float + + +@dataclass(frozen=True) +class Scenario: + description: str + build_situation: Callable[[CouncilConfig], dict] + + +SINGLE_JSA_WEEKLY = 92.05 +COUPLE_JSA_WEEKLY = 144.65 +STATE_PENSION_WEEKLY = 185.15 + + +COUNCILS = { + "east_hertfordshire": CouncilConfig( + label="East Hertfordshire", + local_authority="EAST_HERTFORDSHIRE", + council_tax_band="D", + council_tax=1800.0, + ), + "warrington": CouncilConfig( + label="Warrington", + local_authority="WARRINGTON", + council_tax_band="C", + council_tax=126.74 * 12, + ), + "dudley": CouncilConfig( + label="Dudley", + local_authority="DUDLEY", + council_tax_band="C", + council_tax=113.25 * 12, + ), + "stroud": CouncilConfig( + label="Stroud", + local_authority="STROUD", + council_tax_band="D", + council_tax=148.09 * 12, + ), +} + + +def base_benunit( + *, + members: list[str], + eldest_adult_age: int, + is_single_person: bool, + is_couple: bool, + is_lone_parent: bool, +) -> dict: + return { + "members": members, + "claims_all_entitled_benefits": {2025: False}, + "would_claim_uc": {2025: False}, + "is_single_person": {2025: is_single_person}, + "is_couple": {2025: is_couple}, + "is_lone_parent": {2025: is_lone_parent}, + "eldest_adult_age": {2025: eldest_adult_age}, + "benefits_premiums": {2025: 0}, + } + + +def base_household(council: CouncilConfig, members: list[str]) -> dict: + return { + "members": members, + "country": {2025: "ENGLAND"}, + "local_authority": {2025: council.local_authority}, + "council_tax_band": {2025: council.council_tax_band}, + "council_tax": {2025: council.council_tax}, + "savings": {2025: 0}, + } + + +def build_single_jsa(council: CouncilConfig) -> dict: + return { + "people": { + "claimant": { + "age": {2025: 35}, + "jsa_income_reported": {2025: SINGLE_JSA_WEEKLY * 52}, + "council_tax_benefit_reported": {2025: 1}, + } + }, + "benunits": { + "benunit": base_benunit( + members=["claimant"], + eldest_adult_age=35, + is_single_person=True, + is_couple=False, + is_lone_parent=False, + ) + }, + "households": { + "household": base_household(council, ["claimant"]), + }, + } + + +def build_couple_jsa(council: CouncilConfig) -> dict: + return { + "people": { + "claimant": { + "age": {2025: 35}, + "jsa_income_reported": {2025: COUPLE_JSA_WEEKLY * 52}, + "council_tax_benefit_reported": {2025: 1}, + }, + "partner": { + "age": {2025: 35}, + }, + }, + "benunits": { + "benunit": base_benunit( + members=["claimant", "partner"], + eldest_adult_age=35, + is_single_person=False, + is_couple=True, + is_lone_parent=False, + ) + }, + "households": { + "household": base_household(council, ["claimant", "partner"]), + }, + } + + +def build_lone_parent_one_child_jsa(council: CouncilConfig) -> dict: + return { + "people": { + "claimant": { + "age": {2025: 35}, + "jsa_income_reported": {2025: SINGLE_JSA_WEEKLY * 52}, + "council_tax_benefit_reported": {2025: 1}, + }, + "child": { + "age": {2025: 5}, + }, + }, + "benunits": { + "benunit": base_benunit( + members=["claimant", "child"], + eldest_adult_age=35, + is_single_person=False, + is_couple=False, + is_lone_parent=True, + ) + }, + "households": { + "household": base_household(council, ["claimant", "child"]), + }, + } + + +def build_single_pension_credit(council: CouncilConfig) -> dict: + return { + "people": { + "claimant": { + "age": {2025: 70}, + "state_pension": {2025: STATE_PENSION_WEEKLY * 52}, + "council_tax_benefit_reported": {2025: 1}, + } + }, + "benunits": { + "benunit": { + **base_benunit( + members=["claimant"], + eldest_adult_age=70, + is_single_person=True, + is_couple=False, + is_lone_parent=False, + ), + "would_claim_pc": {2025: True}, + } + }, + "households": { + "household": base_household(council, ["claimant"]), + }, + } + + +SCENARIOS = { + "single_jsa": Scenario( + description="Single working-age claimant on income-based JSA.", + build_situation=build_single_jsa, + ), + "couple_jsa": Scenario( + description="Couple with no children on income-based JSA.", + build_situation=build_couple_jsa, + ), + "lone_parent_one_child_jsa": Scenario( + description="Lone parent with one child on income-based JSA.", + build_situation=build_lone_parent_one_child_jsa, + ), + "single_pension_credit": Scenario( + description="Single pension-age claimant with State Pension and Pension Credit.", + build_situation=build_single_pension_credit, + ), +} + + +def run_case(council_name: str, scenario_name: str) -> dict: + council = COUNCILS[council_name] + scenario = SCENARIOS[scenario_name] + sim = Simulation(situation=scenario.build_situation(council)) + period = 2025 + return { + "council": council_name, + "council_label": council.label, + "scenario": scenario_name, + "description": scenario.description, + "council_tax_band": council.council_tax_band, + "council_tax": council.council_tax, + "council_tax_reduction": float(sim.calculate("council_tax_reduction", period)[0]), + "council_tax_less_benefit": float( + sim.calculate("council_tax_less_benefit", period)[0] + ), + "child_benefit": float(sim.calculate("child_benefit", period)[0]), + "pension_credit": float(sim.calculate("pension_credit", period)[0]), + "state_pension": float(sim.calculate("state_pension", period)[0]), + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--scenario", + action="append", + choices=[*SCENARIOS.keys(), "all"], + default=None, + ) + parser.add_argument( + "--council", + action="append", + choices=[*COUNCILS.keys(), "all"], + default=None, + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + scenario_names = args.scenario or ["single_jsa"] + if "all" in scenario_names: + scenario_names = list(SCENARIOS.keys()) + else: + scenario_names = list(dict.fromkeys(scenario_names)) + + council_names = args.council or ["all"] + if "all" in council_names: + council_names = list(COUNCILS.keys()) + else: + council_names = list(dict.fromkeys(council_names)) + + results = [] + for scenario_name in scenario_names: + for council_name in council_names: + results.append(run_case(council_name, scenario_name)) + + json.dump(results, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/turn2us_ctr_compare.py b/scripts/turn2us_ctr_compare.py new file mode 100644 index 000000000..bc0c05533 --- /dev/null +++ b/scripts/turn2us_ctr_compare.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 + +import argparse +import json +import sys +from dataclasses import dataclass +from typing import Any, Callable + +import requests + + +BASE_URL = "https://benefits-calculator-api.turn2us.org.uk" +USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/136.0.0.0 Safari/537.36" +) +STATE = { + "partner": None, + "referral": None, + "subCategory": None, + "test": None, + "pipSessionId": None, + "isMobile": False, + "browserVersion": "136.0.0.0", + "browserName": "Chrome", + "OsName": "Mac OS", + "browserId": "policyengine_turn2us_probe", +} + + +@dataclass(frozen=True) +class Scenario: + description: str + build_steps: Callable[[dict[str, Any]], list[dict[str, Any]]] + + +def build_single_jsa_steps(postcode_payload: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "ClientMemberHousehold": "Yes", + "ClientPartnerStudent": "No", + "ClientPartnerPrison": "No", + "ClientPartnerHospital": "No", + }, + {}, + { + "ClientPostcode": postcode_payload, + "ClientLiveWithPartner": "No", + "ClientDob": "01/01/1991", + }, + {}, + {"ClientPartnerReceivingBens": "Yes"}, + { + "ClientPartnerUcReceiving": "No", + "ClientPartnerIrEsa": "No", + "ClientPartnerIncomeSupport": "No", + "ClientPartnerIbJsa": "Yes", + "IbJsaSingleCouple": "Single", + "ClientPartnerHousingBenefit": "No", + "ManagedMigration": "No", + }, + {"ClientIncomeFromBenefits": ["None of the above"]}, + {}, + {"ClientSicknessDisability": "No", "ClientRegisteredBlind": "No"}, + {}, + {"ClientCarer": "No", "ClientCarerAllow": "No", "ClientCarerSupport": "No"}, + {}, + { + "ClientIsApprovedFosterCarer": "No", + "ClientHasChildren": "No", + "ClientExpectingBaby": "No", + }, + {}, + {}, + {"ClientHousingCosts": "I own my home with a mortgage"}, + {"BetterOffCalc": "No", "ClientWorking": "Unemployed and looking for work"}, + {"ClientWarPension": "No"}, + {"ClientSpousalMaintenance": "No"}, + {}, + {"ClientPropertyCommercial": "No"}, + {"ClientCapitalEstimate": "£0 - £5000"}, + ] + + +def build_couple_jsa_steps(postcode_payload: dict[str, Any]) -> list[dict[str, Any]]: + steps = build_single_jsa_steps(postcode_payload) + steps[2] = { + **steps[2], + "ClientLiveWithPartner": "Yes", + "PartnerDob": "01/01/1991", + } + steps[5] = {**steps[5], "IbJsaSingleCouple": "Couple"} + # Turn2us inserts partner-only work / pension pages for couples. + steps.extend([{}, {}, {}, {}, {}]) + return steps + + +def build_lone_parent_one_child_jsa_steps( + postcode_payload: dict[str, Any] +) -> list[dict[str, Any]]: + steps = build_single_jsa_steps(postcode_payload) + steps[12] = { + **steps[12], + "ClientHasChildren": "Yes", + "ClientDependentChildren": "1", + "ClientExpectingBaby": "No", + } + steps[13] = { + "ChildGenderOne": "Male", + "ChildDobOne": "01/01/2021", + "ChildDisabilityOne": "No", + } + return steps + + +def build_single_pension_credit_steps( + postcode_payload: dict[str, Any] +) -> list[dict[str, Any]]: + steps = build_single_jsa_steps(postcode_payload) + steps[2] = {**steps[2], "ClientDob": "01/01/1956"} + steps[5] = { + "ClientPartnerUcReceiving": "No", + "ClientPartnerIrEsa": "No", + "ClientPartnerIncomeSupport": "No", + "ClientPartnerIbJsa": "No", + "ClientPartnerHousingBenefit": "No", + "ClientPartnerPensionCredit": "Yes", + "ClientSavingPension": "Yes", + "ManagedMigration": "No", + } + steps[17] = { + "ClientIncomeStatePension": {"weekly": "185.15", "period": "weekly"}, + "ClientStatePensionDeferred": {"weekly": "0", "period": "weekly"}, + "ClientIncomeOccupationalPension": {"weekly": "0", "period": "weekly"}, + } + return steps + + +SCENARIOS = { + "single_jsa": Scenario( + description="Single working-age claimant on income-based JSA.", + build_steps=build_single_jsa_steps, + ), + "couple_jsa": Scenario( + description="Couple with no children on income-based JSA.", + build_steps=build_couple_jsa_steps, + ), + "lone_parent_one_child_jsa": Scenario( + description="Lone parent with one child on income-based JSA.", + build_steps=build_lone_parent_one_child_jsa_steps, + ), + "single_pension_credit": Scenario( + description="Single pension-age claimant with Pension Credit and State Pension.", + build_steps=build_single_pension_credit_steps, + ), +} + + +def build_session() -> requests.Session: + session = requests.Session() + session.headers.update( + { + "User-Agent": USER_AGENT, + "Accept": "application/json, text/plain, */*", + "Origin": "https://benefits-calculator.turn2us.org.uk", + "Referer": "https://benefits-calculator.turn2us.org.uk/", + } + ) + return session + + +def resolve_postcode(session: requests.Session, postcode: str) -> dict[str, Any]: + response = session.post( + f"{BASE_URL}/api/data/postcode/", + json={"postcode": postcode, "browserId": STATE["browserId"]}, + timeout=30, + ) + response.raise_for_status() + payload = response.json() + if not payload.get("valid"): + raise RuntimeError(f"Turn2us rejected postcode {postcode!r}: {payload}") + return payload["value"] + + +def start_survey(session: requests.Session) -> dict[str, Any]: + response = session.get(f"{BASE_URL}/api/data/survey", timeout=30) + response.raise_for_status() + return response.json() + + +def advance_survey( + session: requests.Session, state: dict[str, Any], answers: dict[str, Any] +) -> dict[str, Any]: + payload = { + **answers, + "session": state["session"], + "page": state["pageNumber"], + "action": 2, + **STATE, + } + response = session.post(f"{BASE_URL}/api/data/survey/", json=payload, timeout=30) + response.raise_for_status() + return response.json() + + +def fetch_results(session: requests.Session, survey_state: dict[str, Any]) -> dict[str, Any]: + response = session.get( + f"{BASE_URL}/api/data/result/?session={survey_state['session']}", + timeout=30, + ) + response.raise_for_status() + return response.json() + + +def benefit_names(result: dict[str, Any], bucket: str) -> list[str]: + return [ + item.get("benefitName") + for item in result["benefits"].get(bucket, []) or [] + if item.get("benefitName") + ] + + +def bucket_totals(result: dict[str, Any], bucket: str) -> list[dict[str, Any]]: + return [ + { + "benefit_name": item.get("benefitName"), + "weekly_payment": item.get("weeklyPayment"), + "monthly_payment": item.get("monthlyPayment"), + } + for item in result["benefits"].get(bucket, []) or [] + ] + + +def find_council_tax_mentions(result: dict[str, Any]) -> list[str]: + mentions: list[str] = [] + for bucket in ("meansTested", "nonMeansTested", "universalCredit"): + for item in result["benefits"].get(bucket, []) or []: + benefit_name = item.get("benefitName") or "" + if "council" in benefit_name.lower(): + mentions.append(benefit_name) + for element in item.get("elements", []) or []: + name = element.get("name") or "" + if "council" in name.lower(): + mentions.append(name) + for deduction in item.get("deductions", []) or []: + name = deduction.get("name") or "" + if "council" in name.lower(): + mentions.append(name) + return mentions + + +def run_case(postcode: str, scenario_name: str) -> dict[str, Any]: + session = build_session() + postcode_payload = resolve_postcode(session, postcode) + survey_state = start_survey(session) + scenario = SCENARIOS[scenario_name] + for answers in scenario.build_steps(postcode_payload): + survey_state = advance_survey(session, survey_state, answers) + for _ in range(10): + if survey_state["pageNumber"] == 109: + break + survey_state = advance_survey(session, survey_state, {}) + result = fetch_results(session, survey_state) + return { + "scenario": scenario_name, + "description": scenario.description, + "postcode": postcode, + "authority": postcode_payload["authority"], + "authority_code": postcode_payload["authorityCode"], + "country": postcode_payload["country"], + "final_page_number": survey_state["pageNumber"], + "final_page_title": survey_state["page"]["title"], + "reached_results_page": survey_state["pageNumber"] == 109, + "means_tested": bucket_totals(result, "meansTested"), + "universal_credit": bucket_totals(result, "universalCredit"), + "non_means_tested": bucket_totals(result, "nonMeansTested"), + "council_tax_mentions": find_council_tax_mentions(result), + "messages_with_council_tax_terms": [ + message + for message in result.get("messages", []) or [] + if "council" in json.dumps(message).lower() + ], + "raw_result_path": None, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("postcode", nargs="+") + parser.add_argument( + "--scenario", + action="append", + choices=[*SCENARIOS.keys(), "all"], + default=None, + ) + parser.add_argument("--save-raw-dir") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + scenario_names = args.scenario or ["single_jsa"] + if "all" in scenario_names: + scenario_names = list(SCENARIOS.keys()) + else: + scenario_names = list(dict.fromkeys(scenario_names)) + results = [] + for scenario_name in scenario_names: + for postcode in args.postcode: + case = run_case(postcode, scenario_name) + if args.save_raw_dir is not None: + from pathlib import Path + + raw_dir = Path(args.save_raw_dir) + raw_dir.mkdir(parents=True, exist_ok=True) + out_path = raw_dir / ( + f"{scenario_name}_{postcode.replace(' ', '_')}.json" + ) + out_path.write_text(json.dumps(case, indent=2)) + case["raw_result_path"] = str(out_path) + results.append(case) + json.dump(results, sys.stdout, indent=2) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())