Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion LEGISLATIVE_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,22 @@ yet migrated (governed by `uc_migration.housing_benefit` rates).
Source: SI 2006/213 reg.70 ([`uksi/2006/213/regulation/70`](https://www.legislation.gov.uk/uksi/2006/213/regulation/70))

Maximum HB = 100% of eligible rent (subject to LHA caps for private renters). The model does
not compute LHA caps; it uses reported rent directly.
not compute LHA caps; it uses reported rent directly as eligible rent for all tenures.

LHA cap is implemented in `src/variables/benefits.rs` via `lha_monthly_cap()`. When `params.lha`
is present and enabled, eligible rent for private renters (TenureType::RentPrivately) is capped
at the regional LHA rate for the household's bedroom entitlement, computed by
`lha_bedroom_entitlement()` (implements UC Regs 2013 Sch.4 / HB Regs 2006 Sch.B1).

The LHA rates are stored as region×category monthly amounts in `params.lha.rates_monthly`.
Since the FRS suppresses BRMA identifiers for disclosure reasons, the model uses region-level
30th percentile rates (derived from the VOA list of rents via policyengine-uk, uprated using the
ONS Index of Private Housing Rental Prices). This understates within-region variation but
captures the main regional gradient. The 2025/26 baseline uses rates frozen at the 2024/25
reset level (April 2024 reset to 30th percentile, re-frozen April 2025).

Reform scenarios can vary `params.lha.private_rent_index` (multiplicative uprating of all rates,
e.g. 1.10 = 10% increase) without changing the underlying rate table.

### 7.2 Applicable Amount

Expand Down
1 change: 1 addition & 0 deletions changelog.d/added/lha-cap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement Local Housing Allowance cap for private renters on HB and UC: bedroom entitlement logic (UC Regs 2013 Sch.4), region×category monthly rate table derived from VOA rent data uprated to 2024/25, and a `private_rent_index` scalar for modelling LHA unfreeze or rate changes.
1 change: 1 addition & 0 deletions changelog.d/added/wales-green-manifesto.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add Wales-specific modelling infrastructure (devolved council tax Band D rates, LTT, `is_in_wales` flag, universal FSM by nation) and score the first two Wales Green Manifesto 2026 policies: council tax abolition in Wales (~£3.7bn/yr) and extending universal free school meals to secondary pupils.
71 changes: 70 additions & 1 deletion parameters/2025_26.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,11 @@ tobacco_duty:
council_tax:
# Local Government Finance Act 1992 s.1-5
# DLUHC Council Tax levels statistics 2025/26: England average Band D = £2,280
# Wales 2025/26 average Band D: £1,955 (Stats Wales / DLUHC)
# Scotland 2025/26 average Band D: £1,528 (Scottish Government)
average_band_d: 2280.0
wales_average_band_d: 1955.0
scotland_average_band_d: 1528.0

capital_gains_tax:
# Taxation of Chargeable Gains Act 1992; Finance Act 2024 s.7 (rate increases)
Expand All @@ -247,7 +251,8 @@ capital_gains_tax:
realisation_rate: 0.50

stamp_duty:
# Finance Act 2003 s.55, as amended; bands from 1 April 2025
# Finance Act 2003 s.55, as amended; bands from 1 April 2025 (England and NI only)
# Wales uses LTT (see land_transaction_tax below); Scotland uses LBTT (not yet separate)
bands:
- { rate: 0.0, threshold: 0 }
- { rate: 0.02, threshold: 125001 }
Expand All @@ -256,13 +261,77 @@ stamp_duty:
- { rate: 0.12, threshold: 1500001 }
annual_purchase_probability: 0.043 # ~1/23 year average holding period

land_transaction_tax:
# Land Transaction Tax and Anti-avoidance of Devolved Taxes (Wales) Act 2017 (ANAW 2017/1)
# Welsh Government rates from 10 October 2022 (still current 2025/26)
# Welsh Revenue Authority: https://www.wra.gov.wales/land-transaction-tax/
# Residential main rates (not higher rates surcharge):
# 0% £0–£225,000
# 6% £225,001–£400,000
# 7.5% £400,001–£750,000
# 10% £750,001–£1,500,000
# 12% over £1,500,000
bands:
- { rate: 0.0, threshold: 0 }
- { rate: 0.06, threshold: 225001 }
- { rate: 0.075, threshold: 400001 }
- { rate: 0.10, threshold: 750001 }
- { rate: 0.12, threshold: 1500001 }
annual_purchase_probability: 0.043

free_school_meals:
# Welsh Government: universal free school meals for primary pupils (reception–year 6)
# rolled out from January 2024. Source: https://www.gov.wales/universal-primary-free-school-meals
# Cost per meal: approx £3.00 (Welsh Government estimate); 190 school days per year.
primary_annual_value: 570.0 # £3.00 × 190 days
secondary_annual_value: 570.0 # same unit cost assumption
# Baseline: Wales primary already universal
wales_primary_universal: true
# Baseline: Wales secondary NOT universal (Green proposal to extend it)
wales_secondary_universal: false

wealth_tax:
# Hypothetical — no current UK legislation. Disabled by default.
# Wealth Tax Commission (2020) proposed 1% above £10m.
enabled: false
threshold: 10000000.0
rate: 0.01

# OBR fiscal headroom (UK-wide — not devolved).
# Source: OBR Spring Statement March 2026, Economic and Fiscal Outlook.
# Headroom against the supplementary debt rule: PSND ex (ex. BoE) falling as a % of GDP
# in the fifth year of the forecast (2029/30). At Spring Statement 2026: £9.9bn.
# Each £1bn of net new borrowing reduces this headroom by approximately £1bn
# (obr_headroom_sensitivity = 1.0 is the OBR's central assumption absent dynamic scoring).
lha:
# Local Housing Allowance rates for 2025/26.
# LHA was re-frozen in April 2025 at the 2024/25 reset rates.
# 2024/25 rates: 30th percentile of private rents in each BRMA, reset from freeze.
# Source: VOA list of rents (2019–2020 data), uprated to April 2024 using ONS Index of
# Private Housing Rental Prices (index: April 2020 = 109.0, April 2024 ≈ 125.0;
# uprate factor = 1.1468). Aggregated to GOR (region) as FRS suppresses BRMA identifiers.
# Categories: [A=shared, B=1-bed, C=2-bed, D=3-bed, E=4+bed] — monthly amounts (£).
# Row order: NE, NW, Yorkshire, E.Midlands, W.Midlands, E.ofEngland, London,
# S.East, S.West, Wales, Scotland, N.Ireland.
enabled: true
private_rent_index: 1.0 # 1.0 = frozen (no uprating). Reform: set >1.0 to unfreeze.
rates_monthly:
- [323.01, 428.86, 486.06, 566.12, 766.23] # North East
- [332.95, 457.44, 566.12, 657.60, 860.11] # North West
- [323.01, 451.72, 543.26, 600.41, 857.72] # Yorkshire
- [372.71, 486.06, 600.41, 686.18, 914.92] # East Midlands
- [390.10, 514.63, 629.03, 743.38, 972.12] # West Midlands
- [360.23, 629.03, 771.95, 886.35, 1252.29] # East of England
- [580.97, 1200.81, 1469.61, 1735.40, 2172.93] # London
- [392.58, 737.66, 914.92, 1086.46, 1543.90] # South East
- [390.10, 571.83, 737.66, 874.87, 1143.66] # South West
- [299.41, 419.92, 514.63, 566.12, 686.18] # Wales
- [323.01, 468.91, 543.26, 629.03, 886.35] # Scotland
- [273.42, 361.97, 428.86, 514.63, 566.12] # Northern Ireland

obr_fiscal_headroom_bn: 9.9
obr_headroom_sensitivity: 1.0

labour_supply:
# OBR labour supply elasticities (Slutsky decomposition).
# Source: OBR (2023) "Costing a cut in National Insurance contributions:
Expand Down
38 changes: 38 additions & 0 deletions reforms/wales_green_manifesto.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Wales Green Party Manifesto 2026 — scoreable policies
#
# This reform covers the Tier 1 and Tier 2 policies from the Wales Green
# Manifesto that can be modelled as parameter changes. Applies for 2025/26.
#
# Policies included:
# 1. Abolish council tax (Wales) — replace with LVT (Tier 3, not yet modelled);
# the abolition side is scored here by zeroing the calculated Band D rate.
# Revenue loss is captured in the fiscal impact output.
# 2. Extend free school meals to secondary pupils in Wales (universal,
# wales_secondary_universal: true). Baseline already has universal primary.
#
# Policies NOT yet included (require additional model components):
# - LHA unfreeze: LHA cap not yet modelled (uses reported rent directly)
# - LVT replacement: new tax base — Tier 3 structural reform
# - Bus fare subsidies: no transport cost variable yet
# - Childcare expansion: childcare offer not yet parameterised
# - Rent freeze: rent is exogenous input

council_tax:
# Abolish council tax in Wales only. Sets wales_average_band_d to zero, which
# scales Welsh households' CT to £0 while leaving England/Scotland unchanged.
# The fiscal cost is the Welsh CT revenue only (~5% of UK total CT).
# Source: Wales Green Manifesto 2026 — "council tax is unfair... we will replace
# it with a land value tax that cuts the average bill"
average_band_d: 2280.0
wales_average_band_d: 0.0
scotland_average_band_d: 1528.0

free_school_meals:
# Extend universal FSM entitlement to secondary pupils (ages 11–15) in Wales.
# Baseline: universal primary already active (wales_primary_universal: true).
# Source: Wales Green Manifesto 2026 — "extend universal free school meals to
# secondary schools, helping reduce inequality"
wales_primary_universal: true
wales_secondary_universal: true
primary_annual_value: 570.0
secondary_annual_value: 570.0
12 changes: 9 additions & 3 deletions src/data/clean.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ fn write_persons(dataset: &Dataset, output_dir: &Path) -> anyhow::Result<()> {
"property_income", "maintenance_income",
"miscellaneous_income", "other_income",
// Employment
"is_in_scotland", "hours_worked_annual",
"is_in_scotland", "is_in_wales", "hours_worked_annual",
// Disability rate-band flags
"dla_care_low", "dla_care_mid", "dla_care_high",
"dla_mob_low", "dla_mob_high",
Expand Down Expand Up @@ -86,6 +86,7 @@ fn write_persons(dataset: &Dataset, output_dir: &Path) -> anyhow::Result<()> {
format!("{:.2}", p.miscellaneous_income),
format!("{:.2}", p.other_income),
p.is_in_scotland.to_string(),
p.is_in_wales.to_string(),
format!("{:.1}", p.hours_worked),
p.dla_care_low.to_string(),
p.dla_care_mid.to_string(),
Expand Down Expand Up @@ -340,7 +341,7 @@ fn write_microdata_csv_persons<W: std::io::Write>(
"property_income", "maintenance_income",
"miscellaneous_income", "other_income",
// Employment
"is_in_scotland", "hours_worked_annual",
"is_in_scotland", "is_in_wales", "hours_worked_annual",
// Status
"is_disabled", "is_carer",
// Contributions
Expand Down Expand Up @@ -384,6 +385,7 @@ fn write_microdata_csv_persons<W: std::io::Write>(
format!("{:.2}", p.miscellaneous_income),
format!("{:.2}", p.other_income),
p.is_in_scotland.to_string(),
p.is_in_wales.to_string(),
format!("{:.1}", p.hours_worked),
p.is_disabled.to_string(),
p.is_carer.to_string(),
Expand Down Expand Up @@ -672,12 +674,15 @@ pub fn assemble_dataset(
bu.would_claim_jsa = bu.person_ids.iter().any(|&pid| people.get(pid).map_or(false, |p| p.jsa_income > 0.0));
}
}
// Auto-derive is_in_scotland from household region
// Auto-derive is_in_scotland / is_in_wales from household region
for p in &mut people {
if let Some(hh) = households.get(p.household_id) {
if hh.region.is_scotland() {
p.is_in_scotland = true;
}
if hh.region.is_wales() {
p.is_in_wales = true;
}
}
}
Dataset {
Expand Down Expand Up @@ -838,6 +843,7 @@ pub fn parse_persons_csv<R: std::io::Read>(reader: R) -> anyhow::Result<Vec<Pers
miscellaneous_income: h.get_f64(&r, "miscellaneous_income"),
other_income: h.get_f64(&r, "other_income"),
is_in_scotland: h.get_bool(&r, "is_in_scotland"),
is_in_wales: h.get_bool(&r, "is_in_wales"),
hours_worked: h.get_f64(&r, "hours_worked_annual"),
dla_care_low: h.get_bool(&r, "dla_care_low"),
dla_care_mid: h.get_bool(&r, "dla_care_mid"),
Expand Down
2 changes: 2 additions & 0 deletions src/data/frs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,7 @@ fn assemble_dataset(
if let Some(&bu_idx) = bu_map.get(&bu_key) {
let pid = people.len();
let is_scotland = households[hh_idx].region.is_scotland();
let is_wales = households[hh_idx].region.is_wales();

people.push(Person {
id: pid,
Expand All @@ -994,6 +995,7 @@ fn assemble_dataset(
miscellaneous_income: pr.miscellaneous_income_weekly * WEEKS_IN_YEAR,
other_income: 0.0,
is_in_scotland: is_scotland,
is_in_wales: is_wales,
hours_worked: pr.hours_worked_weekly * 52.0,
dla_care_low: pr.dla_care_low,
dla_care_mid: pr.dla_care_mid,
Expand Down
26 changes: 26 additions & 0 deletions src/engine/entities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub struct Person {

// Employment
pub is_in_scotland: bool,
pub is_in_wales: bool,
pub hours_worked: f64, // annual hours

// Disability/carer status — granular rate-band flags derived from FRS benefit amounts
Expand Down Expand Up @@ -124,6 +125,7 @@ impl Default for Person {
miscellaneous_income: 0.0,
other_income: 0.0,
is_in_scotland: false,
is_in_wales: false,
hours_worked: 0.0,
dla_care_low: false,
dla_care_mid: false,
Expand Down Expand Up @@ -507,6 +509,10 @@ impl Region {
matches!(self, Region::Scotland)
}

pub fn is_wales(&self) -> bool {
matches!(self, Region::Wales)
}

pub fn from_frs_code(code: i32) -> Self {
match code {
1 => Region::NorthEast,
Expand Down Expand Up @@ -560,6 +566,26 @@ impl Region {
}
}

/// LHA region index for rate table lookup (0–11, matching rates_monthly row order).
/// Order: NE=0, NW=1, Yorks=2, EM=3, WM=4, EofE=5, London=6, SE=7, SW=8,
/// Wales=9, Scotland=10, NI=11.
pub fn to_lha_region_idx(&self) -> usize {
match self {
Region::NorthEast => 0,
Region::NorthWest => 1,
Region::Yorkshire => 2,
Region::EastMidlands => 3,
Region::WestMidlands => 4,
Region::EastOfEngland => 5,
Region::London => 6,
Region::SouthEast => 7,
Region::SouthWest => 8,
Region::Wales => 9,
Region::Scotland => 10,
Region::NorthernIreland => 11,
}
}

pub fn name(&self) -> &'static str {
match self {
Region::NorthEast => "North East",
Expand Down
47 changes: 33 additions & 14 deletions src/engine/simulation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ pub struct BenUnitResult {
pub total_benefits: f64,
pub uc_max_amount: f64,
pub uc_income_reduction: f64,
/// Calculated free school meals value. When `free_school_meals` params are present,
/// this reflects eligibility-based modelling (e.g. Wales universal extension).
/// Falls back to the FRS-reported amount if no params provided.
pub free_school_meals: f64,
}

/// Results for a household
Expand Down Expand Up @@ -262,11 +266,18 @@ impl Simulation {
.map(|&pid| self.people[pid].employee_pension_contributions + self.people[pid].personal_pension_contributions)
.sum();

// In-kind benefits included in HBAI net income
// In-kind benefits included in HBAI net income.
// When FSM params are present, use the calculated modelled amount rather
// than the raw FRS-reported figure so that reform scenarios apply correctly.
let in_kind_benefits: f64 = hh.benunit_ids.iter()
.map(|&bid| {
let bu = &self.benunits[bid];
bu.free_school_meals + bu.free_school_fruit_veg + bu.free_school_milk
let fsm = if self.parameters.free_school_meals.is_some() {
benunit_results[bid].free_school_meals
} else {
bu.free_school_meals
};
fsm + bu.free_school_fruit_veg + bu.free_school_milk
+ bu.healthy_start_vouchers + bu.free_tv_licence
})
.sum();
Expand Down Expand Up @@ -299,10 +310,13 @@ impl Simulation {
.map(|&pid| person_results[pid].capital_gains_tax)
.sum();

// Stamp duty (annualised)
let stamp_duty = self.parameters.stamp_duty.as_ref()
.map(|p| variables::wealth_taxes::calculate_stamp_duty(hh, p))
.unwrap_or(0.0);
// Property transaction tax: LTT for Wales, SDLT for England/NI.
// Routes automatically based on household region.
let stamp_duty = variables::wealth_taxes::calculate_property_transaction_tax(
hh,
self.parameters.stamp_duty.as_ref(),
self.parameters.land_transaction_tax.as_ref(),
);

// Wealth tax
let wealth_tax = self.parameters.wealth_tax.as_ref()
Expand All @@ -314,14 +328,19 @@ impl Simulation {
.map(|p| variables::wealth_taxes::calculate_council_tax(hh, p))
.unwrap_or(hh.council_tax);

// Council tax enters total_tax for fiscal accounting, and housing costs
// for AHC. Both use council_tax_calculated so that reform scenarios
// (e.g. abolition or revaluation) flow through correctly.
let total_tax = direct_tax + vat + fuel_duty + alcohol_duty + tobacco_duty
+ cgt + stamp_duty + wealth_tax;
// HBAI net income: gross minus direct taxes and pension contributions, plus benefits.
// Excludes indirect taxes (VAT, duties) and transaction/wealth taxes (SDLT, wealth tax)
// to match the government HBAI definition used for poverty and distributional analysis.
let net_income = net_income_before_vat;
+ cgt + stamp_duty + wealth_tax + council_tax_calculated;
// HBAI net income (BHC): gross minus direct taxes and pension contributions, plus benefits.
// Council tax is a housing cost subtracted in the AHC measure (not BHC), per HBAI convention.
// We adjust BHC net income for the *change* in council tax relative to the FRS-reported
// baseline, so that CT reform scenarios correctly shift households' disposable income.
let ct_adjustment = council_tax_calculated - hh.council_tax;
let net_income = net_income_before_vat - ct_adjustment;
// Extended net income: subtracts indirect and wealth taxes for fiscal analysis.
let extended_net_income = net_income_before_vat - vat - stamp_duty - wealth_tax;
let extended_net_income = net_income - vat - stamp_duty - wealth_tax;

// Modified OECD equivalisation scale (used by HBAI):
// First adult: 0.67, additional adults (14+): 0.33, children (<14): 0.20
Expand All @@ -338,8 +357,8 @@ impl Simulation {
0.67 + (adults.saturating_sub(1) as f64) * 0.33 + (children as f64) * 0.20
};

// AHC: subtract rent and council tax (housing costs), using HBAI net income
let housing_costs = hh.rent + hh.council_tax;
// AHC: subtract rent and council tax (housing costs), using calculated CT.
let housing_costs = hh.rent + council_tax_calculated;
let net_income_ahc = net_income - housing_costs;

HouseholdResult {
Expand Down
Loading
Loading