diff --git a/changelog.d/added/pip-from-flags.md b/changelog.d/added/pip-from-flags.md new file mode 100644 index 0000000..939886f --- /dev/null +++ b/changelog.d/added/pip-from-flags.md @@ -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. diff --git a/interfaces/python/policyengine_uk_compiled/__init__.py b/interfaces/python/policyengine_uk_compiled/__init__.py index e418d5e..0f455ab 100644 --- a/interfaces/python/policyengine_uk_compiled/__init__.py +++ b/interfaces/python/policyengine_uk_compiled/__init__.py @@ -58,6 +58,7 @@ def print_guide(): StampDutyParams, CapitalGainsTaxParams, WealthTaxParams, + PipParams, LabourSupplyParams, Parameters, ) @@ -107,6 +108,7 @@ def print_guide(): "StampDutyParams", "CapitalGainsTaxParams", "WealthTaxParams", + "PipParams", "LabourSupplyParams", "Parameters", ] diff --git a/interfaces/python/policyengine_uk_compiled/models.py b/interfaces/python/policyengine_uk_compiled/models.py index 903947b..fe9a1ef 100644 --- a/interfaces/python/policyengine_uk_compiled/models.py +++ b/interfaces/python/policyengine_uk_compiled/models.py @@ -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). @@ -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 diff --git a/parameters/2025_26.yaml b/parameters/2025_26.yaml index 41df0d5..d6a0b67 100644 --- a/parameters/2025_26.yaml +++ b/parameters/2025_26.yaml @@ -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. diff --git a/src/parameters/mod.rs b/src/parameters/mod.rs index c3743f4..f8a53ed 100644 --- a/src/parameters/mod.rs +++ b/src/parameters/mod.rs @@ -75,6 +75,9 @@ pub struct Parameters { /// for their bedroom entitlement category. Authority: HB Regs 2006 reg.13D. #[serde(default)] pub lha: Option, + /// Personal Independence Payment weekly rates. Welfare Reform Act 2012 s.79. + #[serde(default)] + pub pip: Option, /// 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. @@ -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 diff --git a/src/variables/benefits.rs b/src/variables/benefits.rs index 68edea4..0181c12 100644 --- a/src/variables/benefits.rs +++ b/src/variables/benefits.rs @@ -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 @@ -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 ¶ms.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 ¶ms.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. @@ -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, ¶ms); + 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, ¶ms) - 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, ¶ms) - 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, ¶ms), 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, ¶ms), 0.0); + assert_eq!(pip_mobility_amount(&p, ¶ms), 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, ¶ms), 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(¶ms, &[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(¶ms, &[p.clone()], &bu, &hh).passthrough_benefits; + if let Some(pip) = params.pip.as_mut() { + pip.daily_living_enhanced_weekly *= 2.0; + } + let reformed = calc(¶ms, &[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); + } } diff --git a/tests/policy/pip.yaml b/tests/policy/pip.yaml new file mode 100644 index 0000000..857b766 --- /dev/null +++ b/tests/policy/pip.yaml @@ -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