Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/added/pip-from-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add PIP daily-living and mobility amount computation from existing eligibility flags. Synthetic households built via `Simulation.from_situation` that set `pip_dl_std`/`pip_dl_enh`/`pip_mob_std`/`pip_mob_enh` now produce non-zero PIP amounts (using the new `PipParams` weekly rates), and PIP-rate reforms now flow through to the modelled amount on those households. FRS-recorded amounts continue to pass through unchanged.
2 changes: 2 additions & 0 deletions interfaces/python/policyengine_uk_compiled/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def print_guide():
StampDutyParams,
CapitalGainsTaxParams,
WealthTaxParams,
PipParams,
LabourSupplyParams,
Parameters,
)
Expand Down Expand Up @@ -107,6 +108,7 @@ def print_guide():
"StampDutyParams",
"CapitalGainsTaxParams",
"WealthTaxParams",
"PipParams",
"LabourSupplyParams",
"Parameters",
]
15 changes: 15 additions & 0 deletions interfaces/python/policyengine_uk_compiled/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,20 @@ class WealthTaxParams(BaseModel):
rate: Optional[float] = None


class PipParams(BaseModel):
"""Personal Independence Payment weekly rates.

Welfare Reform Act 2012 s.79; SI 2013/377. Set any of the four weekly
rates to model PIP-rate reforms; recipients are identified by the
`pip_dl_std` / `pip_dl_enh` / `pip_mob_std` / `pip_mob_enh` flags on
each Person.
"""
daily_living_standard_weekly: Optional[float] = None
daily_living_enhanced_weekly: Optional[float] = None
mobility_standard_weekly: Optional[float] = None
mobility_enhanced_weekly: Optional[float] = None


class LabourSupplyParams(BaseModel):
"""OBR labour supply elasticities (Slutsky decomposition).

Expand Down Expand Up @@ -230,6 +244,7 @@ class Parameters(BaseModel):
stamp_duty: Optional[StampDutyParams] = None
lbtt: Optional[StampDutyParams] = None
ltt: Optional[StampDutyParams] = None
pip: Optional["PipParams"] = None
wealth_tax: Optional[WealthTaxParams] = None
labour_supply: Optional[LabourSupplyParams] = None

Expand Down
8 changes: 8 additions & 0 deletions parameters/2025_26.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,14 @@ wealth_tax:
threshold: 10000000.0
rate: 0.01

pip:
# Personal Independence Payment weekly rates from 7 April 2025.
# Welfare Reform Act 2012 s.79 / SI 2013/377. Source: gov.uk/pip/what-youll-get.
daily_living_standard_weekly: 73.90
daily_living_enhanced_weekly: 110.40
mobility_standard_weekly: 29.20
mobility_enhanced_weekly: 77.05

lha:
# Local Housing Allowance rates for 2025/26.
# LHA was re-frozen in April 2025 at the 2024/25 reset rates.
Expand Down
16 changes: 16 additions & 0 deletions src/parameters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ pub struct Parameters {
/// for their bedroom entitlement category. Authority: HB Regs 2006 reg.13D.
#[serde(default)]
pub lha: Option<LhaParams>,
/// Personal Independence Payment weekly rates. Welfare Reform Act 2012 s.79.
#[serde(default)]
pub pip: Option<PipParams>,
/// OBR labour supply response elasticities.
/// When enabled, the Slutsky-decomposition elasticities from OBR (2023) are applied
/// to estimate intensive-margin labour supply responses to tax-benefit reforms.
Expand Down Expand Up @@ -470,6 +473,19 @@ pub struct StampDutyParams {

fn default_purchase_probability() -> f64 { 0.043 }

/// Personal Independence Payment weekly component rates.
///
/// PIP has two components — daily living and mobility — each at a standard or
/// enhanced rate. Welfare Reform Act 2012 s.79 / Social Security (Personal
/// Independence Payment) Regulations 2013 (SI 2013/377). Rates uprated annually.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipParams {
pub daily_living_standard_weekly: f64,
pub daily_living_enhanced_weekly: f64,
pub mobility_standard_weekly: f64,
pub mobility_enhanced_weekly: f64,
}

/// OBR labour supply response elasticities (Slutsky decomposition).
///
/// Source: OBR (2023) "Costing a cut in National Insurance contributions: the
Expand Down
131 changes: 130 additions & 1 deletion src/variables/benefits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ pub fn calculate_benunit(
// All exempt from the benefit cap.
let passthrough_benefits: f64 = bu.person_ids.iter().map(|&pid| {
let p = &people[pid];
p.pip_daily_living + p.pip_mobility
pip_daily_living_amount(p, params) + pip_mobility_amount(p, params)
+ p.dla_care + p.dla_mobility
+ p.attendance_allowance
+ p.esa_contributory
Expand Down Expand Up @@ -378,6 +378,43 @@ pub(crate) fn uc_unearned_income(bu: &BenUnit, people: &[Person]) -> f64 {
/// New SP started April 2016. SP age is 66. So in fiscal year Y, the cutoff
/// is: anyone aged > 66 + (Y - 2016) was already SP-age when new SP began,
/// and is therefore on basic SP. Everyone else on SP is on new SP.
/// PIP daily-living component amount (annual).
///
/// If the person has a recorded amount (`p.pip_daily_living > 0`), returns that
/// amount unchanged — preserves FRS-recorded values which may reflect partial-
/// year claims, transitional protection, or amounts predating a reform. If the
/// recorded amount is 0 but the standard or enhanced flag is set, returns the
/// computed weekly rate × 52 from `params.pip`. Returns 0 when neither holds
/// or when no PIP parameters are loaded.
pub fn pip_daily_living_amount(p: &Person, params: &Parameters) -> f64 {
if p.pip_daily_living > 0.0 {
return p.pip_daily_living;
}
let pip = match &params.pip { Some(p) => p, None => return 0.0 };
if p.pip_dl_enh {
pip.daily_living_enhanced_weekly * 52.0
} else if p.pip_dl_std {
pip.daily_living_standard_weekly * 52.0
} else {
0.0
}
}

/// PIP mobility component amount (annual). See `pip_daily_living_amount`.
pub fn pip_mobility_amount(p: &Person, params: &Parameters) -> f64 {
if p.pip_mobility > 0.0 {
return p.pip_mobility;
}
let pip = match &params.pip { Some(p) => p, None => return 0.0 };
if p.pip_mob_enh {
pip.mobility_enhanced_weekly * 52.0
} else if p.pip_mob_std {
pip.mobility_standard_weekly * 52.0
} else {
0.0
}
}

/// Calculate reform-adjusted state pension for a single person.
/// New SP recipients get the full parameter rate; basic SP recipients get
/// their reported amount scaled by the reform ratio.
Expand Down Expand Up @@ -2259,4 +2296,96 @@ mod parameter_impact_tests {
// HB for private renter at £2500/month rent in London should be capped at 1-bed LHA £1200.81/month
assert!(hb_private <= 1200.81 * 12.0 + 1.0, "HB should not exceed LHA cap for private renter");
}

// ── PIP amount-from-flags ─────────────────────────────────────────────────

#[test]
fn pip_dl_enhanced_from_flag_when_amount_zero() {
let params = Parameters::for_year(2025).unwrap();
let mut p = Person::default();
p.age = 35.0;
p.pip_dl_enh = true;
// 2025/26 PIP DL enhanced: £110.40/week × 52 = £5,740.80
let amount = pip_daily_living_amount(&p, &params);
assert!((amount - 5_740.80).abs() < 0.01, "got {}", amount);
}

#[test]
fn pip_dl_standard_from_flag_when_amount_zero() {
let params = Parameters::for_year(2025).unwrap();
let mut p = Person::default();
p.age = 35.0;
p.pip_dl_std = true;
// £73.90 × 52 = £3,842.80
assert!((pip_daily_living_amount(&p, &params) - 3_842.80).abs() < 0.01);
}

#[test]
fn pip_mob_enhanced_from_flag() {
let params = Parameters::for_year(2025).unwrap();
let mut p = Person::default();
p.age = 35.0;
p.pip_mob_enh = true;
// £77.05 × 52 = £4,006.60
assert!((pip_mobility_amount(&p, &params) - 4_006.60).abs() < 0.01);
}

#[test]
fn pip_recorded_amount_overrides_flag() {
// FRS data: amount may differ from full annual rate (partial year, etc.).
let params = Parameters::for_year(2025).unwrap();
let mut p = Person::default();
p.age = 35.0;
p.pip_dl_enh = true;
p.pip_daily_living = 4_000.0; // recorded — should pass through unchanged
assert_eq!(pip_daily_living_amount(&p, &params), 4_000.0);
}

#[test]
fn pip_no_flag_no_recorded_returns_zero() {
let params = Parameters::for_year(2025).unwrap();
let p = Person::default();
assert_eq!(pip_daily_living_amount(&p, &params), 0.0);
assert_eq!(pip_mobility_amount(&p, &params), 0.0);
}

#[test]
fn pip_returns_zero_when_params_missing() {
let mut params = Parameters::for_year(2025).unwrap();
params.pip = None;
let mut p = Person::default();
p.pip_dl_enh = true;
assert_eq!(pip_daily_living_amount(&p, &params), 0.0);
}

#[test]
fn pip_flows_into_passthrough_benefits() {
// A synthetic household with PIP enhanced flag should see the benefit
// amount appear in `total_benefits`.
let (params, mut p, bu, hh) = base_person_uc();
p.pip_dl_enh = true;
p.pip_mob_std = true;
let result = calc(&params, &[p], &bu, &hh);
// Passthrough = DL enhanced (£5740.80) + Mob standard (£1518.40) = £7259.20
let expected_passthrough = 5_740.80 + 1_518.40;
assert!(result.passthrough_benefits >= expected_passthrough - 0.01,
"passthrough_benefits = {}, expected at least {}",
result.passthrough_benefits, expected_passthrough);
}

#[test]
fn pip_param_change_flows_through() {
// Reform: doubling the DL enhanced rate should double the synthetic
// household's PIP DL amount.
let (mut params, mut p, bu, hh) = base_person_uc();
p.pip_dl_enh = true;
let baseline = calc(&params, &[p.clone()], &bu, &hh).passthrough_benefits;
if let Some(pip) = params.pip.as_mut() {
pip.daily_living_enhanced_weekly *= 2.0;
}
let reformed = calc(&params, &[p], &bu, &hh).passthrough_benefits;
// Reform should add another £5,740.80 of PIP DL enhanced.
assert!((reformed - baseline - 5_740.80).abs() < 0.01,
"baseline={}, reformed={}, delta={}", baseline, reformed, reformed - baseline);
}
}
99 changes: 99 additions & 0 deletions tests/policy/pip.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Personal Independence Payment from eligibility flags.
#
# 2025/26 weekly rates (gov.uk/pip/what-youll-get):
# Daily living standard: £73.90 → £3,842.80/yr
# Daily living enhanced: £110.40 → £5,740.80/yr
# Mobility standard: £29.20 → £1,518.40/yr
# Mobility enhanced: £77.05 → £4,006.60/yr
#
# These cases set `would_claim_*: false` on the benunit to suppress modelled
# means-tested benefits (UC/HB/PC), isolating PIP in `baseline_total_benefits`.

- name: PIP DL enhanced + mobility standard from flags
period: 2025
absolute_error_margin: 1
input:
people:
p: { age: 35, employment_income: 0, pip_dl_enh: true, pip_mob_std: true }
benunits:
b:
members: [p]
would_claim_uc: false
would_claim_hb: false
would_claim_pc: false
would_claim_is: false
would_claim_ctc: false
would_claim_wtc: false
would_claim_esa: false
would_claim_jsa: false
households:
h: { members: [p], region: London }
output:
# £5,740.80 + £1,518.40 = £7,259.20
baseline_total_benefits: 7259

- name: PIP DL standard alone
period: 2025
absolute_error_margin: 1
input:
people:
p: { age: 40, employment_income: 0, pip_dl_std: true }
benunits:
b:
members: [p]
would_claim_uc: false
would_claim_hb: false
would_claim_pc: false
would_claim_is: false
would_claim_ctc: false
would_claim_wtc: false
would_claim_esa: false
would_claim_jsa: false
households:
h: { members: [p], region: London }
output:
baseline_total_benefits: 3843

- name: PIP mobility enhanced alone
period: 2025
absolute_error_margin: 1
input:
people:
p: { age: 40, employment_income: 0, pip_mob_enh: true }
benunits:
b:
members: [p]
would_claim_uc: false
would_claim_hb: false
would_claim_pc: false
would_claim_is: false
would_claim_ctc: false
would_claim_wtc: false
would_claim_esa: false
would_claim_jsa: false
households:
h: { members: [p], region: London }
output:
baseline_total_benefits: 4007

- name: No PIP flags — no benefits when claiming disabled
period: 2025
absolute_error_margin: 0
input:
people:
p: { age: 40, employment_income: 0 }
benunits:
b:
members: [p]
would_claim_uc: false
would_claim_hb: false
would_claim_pc: false
would_claim_is: false
would_claim_ctc: false
would_claim_wtc: false
would_claim_esa: false
would_claim_jsa: false
households:
h: { members: [p], region: London }
output:
baseline_total_benefits: 0