diff --git a/changelog.d/qyp-terminal-date-19.fixed.md b/changelog.d/qyp-terminal-date-19.fixed.md new file mode 100644 index 0000000000..72d9b148e8 --- /dev/null +++ b/changelog.d/qyp-terminal-date-19.fixed.md @@ -0,0 +1 @@ +Placed the 1 September following a qualifying young person's 19th birthday from their date of birth, for Universal Credit and Pension Credit (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 reg. 4A(1)(b)). The model now counts a 19-year-old in education for Universal Credit by default in household calculations, as the microdata already did, and applies the date to Pension Credit, which had no terminal date. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml new file mode 100644 index 0000000000..09616dbd7b --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age from which Pension Credit qualifying young person status is limited to the period before the 1 September following the 19th birthday. +values: + 0001-01-01: 19 +metadata: + unit: year + label: Pension Credit qualifying young person terminal date age limit + reference: + - title: The State Pension Credit Regulations 2002 reg. 4A(1)(b) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml index aaf0b00010..4b062a0214 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml @@ -92,6 +92,9 @@ age: 19 age_started_or_accepted_current_education_or_training: 18 current_education: POST_SECONDARY + # Set explicitly: a person left unset takes the date fact's value, + # which is true for anyone under 20 on 6 October. + is_before_universal_credit_qualifying_young_person_terminal_date: false under_terminal_date_age_limit: age: 18 current_education: POST_SECONDARY diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml new file mode 100644 index 0000000000..4a05b360b7 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml @@ -0,0 +1,378 @@ +# A young person in non-advanced education or approved training is a +# qualifying young person "up to, but not including, the 1st September +# following their 19th birthday" (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 +# reg. 4A(1)(b)). The model places that date from the date of birth and +# counts a year that starts (6 April) before it, so every 19-year-old counts +# unless an input says the date has passed. +# +# 2026-27 amounts: Universal Credit child element for a first child born +# before 6 April 2017, £351.88 a month +# (gov/dwp/universal_credit/elements/child/first/higher_amount.yaml); +# Pension Credit first-child addition, £81.07 a week +# (gov/dwp/pension_credit/guarantee_credit/child/first/addition.yaml). + +- name: Case 1, a 19-year-old at school is a Universal Credit and Pension Credit qualifying young person by default. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_before_universal_credit_qualifying_young_person_terminal_date: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + # 351.88 * 12. + uc_individual_child_element: [0, 4_222.56] + is_uc_work_allowance_eligible: true + +- name: Case 2, the Universal Credit input ends Universal Credit status only. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: false + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + uc_individual_child_element: [0, 0] + is_uc_work_allowance_eligible: false + +- name: Case 3, the date fact set false ends both. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_first_september_following_19th_birthday: false + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + # The parent, not set, takes the default, true: harmless, since every + # reader is behind the qualifying young person age limit. + is_before_universal_credit_qualifying_young_person_terminal_date: [true, false] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + +- name: Case 4, a pensioner gets the Pension Credit child addition for a 19-year-old at school. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_qualifying_young_person_for_pension_credit: [false, true] + # 81.07 * 52. + child_minimum_guarantee_addition: 4_215.64 + +- name: Case 5, no Pension Credit child addition once the 1 September after the 19th birthday has passed. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_first_september_following_19th_birthday: false + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_qualifying_young_person_for_pension_credit: [false, false] + child_minimum_guarantee_addition: 0 + +- name: Case 6, born 31 August 2007, the period ends on the 19th birthday, in the year. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + date_of_birth: 20070831 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + +- name: Case 7, born 15 September 2006, before the date until 31 August 2026 but aged 20 on 6 October. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 20 + date_of_birth: 20060915 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + # The age limit, read on 6 October, excludes them. + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 8, born 31 August 2006, the 1 September after the 19th birthday was in 2025. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 20 + date_of_birth: 20060831 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, false] + is_qualifying_young_person_for_universal_credit: [false, false] + +- name: Case 9, a course begun at 19 does not qualify, whatever the date (reg. 5(2); reg. 4A(2)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 19 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 10, a 19-year-old outside education is not a qualifying young person, whatever the date. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_adult: + age: 19 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, young_adult] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 11, born 1 September 2006, the 1 September following the 19th birthday is in 2026, not the birthday itself. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 20 + date_of_birth: 20060901 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + # The 19th birthday is 1 September 2025; the 1 September that follows it + # is in 2026, inside the year. + is_before_first_september_following_19th_birthday: [false, true] + # The age limit, read on 6 October, excludes them. + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 12, setting the date fact for one 19-year-old leaves another's status as the formula gives it. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + first: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_first_september_following_19th_birthday: false + is_claimant_or_partner: false + second: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, first, second] + output: + # The second takes the default, true, which is the formula's value for + # anyone under 20 on 6 October. + is_before_first_september_following_19th_birthday: [true, false, true] + is_qualifying_young_person_for_universal_credit: [false, false, true] + is_qualifying_young_person_for_pension_credit: [false, false, true] + +- name: Case 13, a 19-year-old at school before the date who reports their own Universal Credit is no qualifying young person. + # UC reg. 5(5) and SPC reg. 4A(5) both list Universal Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + universal_credit_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_before_universal_credit_qualifying_young_person_terminal_date: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + is_uc_work_allowance_eligible: false + +- name: Case 14, a pensioner gets no Pension Credit child addition for a 19-year-old at school before the date who reports their own income-related ESA. + # SPC reg. 4A(5) and UC reg. 5(5) both list employment and support allowance. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + esa_income_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + # The pensioner, aged 70, is neither under 19 nor before the date. + meets_qualifying_young_person_terminal_date_condition_for_pension_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, false] + child_minimum_guarantee_addition: 0 + +- name: Case 15, a pensioner gets no Pension Credit child addition for a 19-year-old at school before the date who reports their own Income Support. + # SPC reg. 4A(5) lists income support; UC reg. 5(5) does not. The model + # pays Income Support until 2026-04-01 (gov.dwp.income_support.active), so + # the case runs in 2025-26. The student is not a claimant or partner, so + # is_on_income_support reads their own report. Without it, the first-child + # addition would be £78.10 a week (2025-04-01) x 52 = £4,061.20. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + income_support_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_on_income_support: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, false] + child_minimum_guarantee_addition: 0 diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 34431e6fab..d7df438ec9 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -64,8 +64,12 @@ contracts, approved providers and interruptions are unobserved. CB/CTC leaver extensions are omitted. UC/PC's route to the 1 September after the 16th birthday, which has no education condition, counts for a year that starts -before that date (invariant 5). UC's terminal date is an input; PC's September -terminal date is absent. +before that date (invariant 5). The tests here set UC's terminal-date input, +false unless a test varies it; PC's terminal date follows the 19th-birthday +date fact, which is true for everyone under 20 when only an age is given +(test_qualifying_young_person_terminal_date_properties.py covers both dates). +Nobody here reports Income Support, which SPC reg 4A(5) lists and UC reg 5(5) +does not. Own-benefit receipt collapses different programme-specific benefit lists. UC/CTC/PC blanket looked-after exclusions omit statutory exceptions. HBAI's fallback assumes dependence at 16--17 regardless of study, requires an diff --git a/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py new file mode 100644 index 0000000000..f60c2f4583 --- /dev/null +++ b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py @@ -0,0 +1,440 @@ +"""The 1 September following the 19th birthday, for Universal Credit and +Pension Credit qualifying young persons. + +A young person enrolled on, or accepted for, non-advanced education or +approved training is a qualifying young person "up to, but not including, +the 1st September following their 19th birthday" (UC Regs 2013 reg. 5(1)(b); +SPC Regs 2002 reg. 4A(1)(b)). + +Invariants: + +1. is_before_first_september_following_19th_birthday is true exactly when + the year starts (6 April) before that 1 September, for every date of + birth, against calendar arithmetic that shares no code with the model. +2. Universal Credit's own terminal-date variable follows that fact unless it + is set, and setting it changes Universal Credit only. +3. Over every combination of age, education, training, apprenticeship, + own-right Universal Credit, ESA or JSA, entry age and the two date facts, + for people not on Income Support: Universal Credit and Pension Credit + agree, and both equal the regulation's conditions. Income Support is the + one exception: SPC reg. 4A(5) lists it and UC reg. 5(5) does not, so a + young person on their own Income Support who otherwise qualifies keeps + the Universal Credit status and loses the Pension Credit one, on either + route. +4. With the 19th-birthday fact on, both statuses are the earlier definition + without a terminal date, so nothing changes below 19 or for a 19-year-old + before the date; switching the fact on never removes the status; and it + matters only for a 19-year-old in education or training. +5. The formula gives true for everyone under 20 on 6 October, whether the + date of birth or only the age is given, and true is each terminal-date + variable's default. So a situation that sets one of them for some people + leaves everyone else's status as it would be with nothing set. That holds + for one variable at a time: a programme variable set for some people gives + everyone else its default, not their date fact, so a situation that also + sets the date fact must set the programme variable for every person whose + status matters (the crossed test). +""" + +from datetime import date, timedelta +from itertools import product +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, assume, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2026 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +SIXTEEN = "is_before_first_september_following_16th_birthday" +NINETEEN = "is_before_first_september_following_19th_birthday" +UC_TERMINAL = "is_before_universal_credit_qualifying_young_person_terminal_date" +UC = "is_qualifying_young_person_for_universal_credit" +PC = "is_qualifying_young_person_for_pension_credit" +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "is_apprentice": False, + "receives_benefits_in_own_right": False, + "age_started_or_accepted_current_education_or_training": 17, + **inputs, + } + + +def simulate(people, year=YEAR): + """Each person alone in a benefit unit: the status tests are personal.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i in range(len(people)): + situation["people"][f"p{i}"] = {} + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = {"members": [f"p{i}"]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def first_september_following_19th_birthday(born): + # A 19th birthday before 1 September is followed by 1 September of the + # same year, and one on or after it by the next year's. A 29 February + # birthday is before 1 September whichever day it is kept on. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/5 + # https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A + return date(born.year + 19 + ((born.month, born.day) >= (9, 1)), 9, 1) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_fact_matches_calendar(births, year): + people = [ + person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}")) + for born in births + ] + expected = [ + date(year, 4, 6) < first_september_following_19th_birthday(born) + for born in births + ] + sim = simulate(people, year) + # Invariant 1. + np.testing.assert_array_equal(sim.calculate(NINETEEN, year), expected) + # Invariant 2: Universal Credit's variable follows it when not set. + np.testing.assert_array_equal(sim.calculate(UC_TERMINAL, year), expected) + return expected + + +def test_fact_for_every_date_of_birth(): + # Every date of birth from 2004 to 2009: ages 16 to 22 on 6 October 2026. + start, end = date(2004, 1, 1), date(2010, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + expected = dict(zip(births, assert_fact_matches_calendar(births, YEAR))) + # 19 on 31 August 2026: the period ends that day, in the year. + assert expected[date(2007, 8, 31)] + # 19 on 31 August 2025: it ended before the year. + assert not expected[date(2006, 8, 31)] + # 19 on 1 September 2025: it runs to 31 August 2026. + assert expected[date(2006, 9, 1)] + # Everyone aged 19 on 6 October 2026 is before the date. + assert all( + before + for born, before in expected.items() + if age_on_6_october(born, YEAR) == 19 + ) + + +@SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_fact_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_fact_matches_calendar(births, year) + + +def studying(p): + # Education status stands in for a non-advanced course of more than 12 + # hours a week; reg. 5(3) and reg. 4A(3) exclude a contract of employment. + return ( + p["current_education"] not in {"NOT_IN_EDUCATION", "TERTIARY"} + and not p["is_apprentice"] + ) or p["is_in_approved_training"] + + +def qualifying_young_person(p, terminal_date=True): + # Reg. 5(1), (2), (5); reg. 4A(1), (2), (5). + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + if p[SIXTEEN]: + return True + if not studying(p): + return False + if ( + p["age"] >= 19 + and p["age_started_or_accepted_current_education_or_training"] >= 19 + ): + return False + return p["age"] < 19 or p[NINETEEN] or not terminal_date + + +GRID = [ + person( + age, + current_education=education, + is_in_approved_training=training, + is_apprentice=apprentice, + receives_benefits_in_own_right=own_right, + age_started_or_accepted_current_education_or_training=entry_age, + **{SIXTEEN: sixteen, NINETEEN: nineteen}, + ) + for age, education, training, apprentice, own_right, entry_age, sixteen in product( + range(14, 22), + EDUCATIONS, + (False, True), + (False, True), + (False, True), + (17, 19, 1000), + (False, True), + ) + for nineteen in (False, True) +] + + +def test_universal_credit_and_pension_credit_agree_on_every_combination(): + sim = simulate(GRID) + # The population invariant 3 covers: Income Support stops paying before + # 2026-27 (gov.dwp.income_support.active), so nobody here is on it. + assert not sim.calculate("is_on_income_support", YEAR).any() + uc, pc = sim.calculate(UC, YEAR), sim.calculate(PC, YEAR) + expected = np.array([qualifying_young_person(p) for p in GRID]) + # Invariant 3. + np.testing.assert_array_equal(uc, expected) + np.testing.assert_array_equal(pc, expected) + # Invariant 4. Rows are adjacent pairs: the 19th-birthday fact off, on. + off, on = expected[0::2], expected[1::2] + assert (on >= off).all() + np.testing.assert_array_equal( + on, [qualifying_young_person(p, terminal_date=False) for p in GRID[1::2]] + ) + changed = on != off + assert changed.any() + assert all( + p["age"] == 19 and studying(p) and not p[SIXTEEN] + for p, c in zip(GRID[1::2], changed) + if c + ) + + +def test_own_income_support_ends_pension_credit_status_only(): + # Invariant 3's exception, in 2025-26, while Income Support pays. A + # pensioner aged 70 with one member aged 16 to 19, over education, entry + # age, both date facts and the member's own reported Income Support. + year = 2025 + rows = list( + product( + range(16, 20), + ("NOT_IN_EDUCATION", "UPPER_SECONDARY"), + (17, 19), + (False, True), + (False, True), + (0, 5_000), + ) + ) + situation = {"people": {}, "benunits": {}, "households": {}} + for i, (age, education, entry_age, sixteen, nineteen, report) in enumerate(rows): + members = [f"pensioner{i}", f"young{i}"] + situation["people"][members[0]] = { + "age": {year: 70}, + "is_parent": {year: True}, + "is_benunit_head": {year: True}, + } + situation["people"][members[1]] = { + "age": {year: age}, + "is_benunit_head": {year: False}, + "current_education": {year: education}, + "age_started_or_accepted_current_education_or_training": {year: entry_age}, + SIXTEEN: {year: sixteen}, + NINETEEN: {year: nineteen}, + "income_support_reported": {year: report}, + } + situation["benunits"][f"b{i}"] = {"members": members} + situation["households"][f"h{i}"] = {"members": members} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + young = slice(1, None, 2) + own_income_support = np.array([row[-1] > 0 for row in rows]) + np.testing.assert_array_equal( + sim.calculate("is_on_income_support", year)[young], own_income_support + ) + expected = np.array( + [ + qualifying_young_person( + person( + age, + current_education=education, + age_started_or_accepted_current_education_or_training=entry_age, + **{SIXTEEN: sixteen, NINETEEN: nineteen}, + ) + ) + for age, education, entry_age, sixteen, nineteen, _ in rows + ] + ) + np.testing.assert_array_equal(sim.calculate(UC, year)[young], expected) + np.testing.assert_array_equal( + sim.calculate(PC, year)[young], expected & ~own_income_support + ) + # Not vacuous: own Income Support removes the Pension Credit status from + # a 19-year-old before the date. + assert any( + e and o and row[0] == 19 and row[4] + for e, o, row in zip(expected, own_income_support, rows) + ) + + +def test_universal_credit_input_overrides_the_fact_for_universal_credit_only(): + # Invariant 2: a 19-year-old at school, with each programme's input set + # against the fact. + rows = list(product((False, True), repeat=2)) + people = [ + person( + 19, + current_education="UPPER_SECONDARY", + **{SIXTEEN: False, NINETEEN: nineteen, UC_TERMINAL: uc_terminal}, + ) + for nineteen, uc_terminal in rows + ] + sim = simulate(people) + np.testing.assert_array_equal(sim.calculate(UC, YEAR), [u for _, u in rows]) + np.testing.assert_array_equal(sim.calculate(PC, YEAR), [n for n, _ in rows]) + + +DEFAULTED = ( + NINETEEN, + UC_TERMINAL, + "meets_qualifying_young_person_terminal_date_condition_for_pension_credit", +) + + +def test_formula_is_true_under_20_and_is_each_default(): + # Invariant 5, from age and months alone: every age to 25 and every + # month since the last birthday, in three years. + rows = list(product(range(26), range(12))) + for year in (2022, 2026, 2030): + situation = {"people": {}, "benunits": {}, "households": {}} + for i, (age, months) in enumerate(rows): + situation["people"][f"p{i}"] = { + "age": {year: age}, + "months_since_last_birthday": {year: months}, + } + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = {"members": [f"p{i}"]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + under_20 = np.array([age < 20 for age, _ in rows]) + for variable in DEFAULTED: + values = sim.calculate(variable, year) + assert values[under_20].all(), (variable, year) + assert sim.tax_benefit_system.variables[variable].default_value is True + # Not vacuous: the fact is false for some people of 20 or more. + assert not sim.calculate(NINETEEN, year)[~under_20].all() + + +def test_setting_one_person_leaves_the_others_status(): + # Invariant 5 in a household: two 19-year-olds at school, course begun + # at 17. Each terminal-date variable is set false for the first only; + # the second stays a qualifying young person for both programmes. + for variable in DEFAULTED: + people = { + "parent": {"age": {YEAR: 70}, "is_claimant_or_partner": {YEAR: True}}, + **{ + name: { + "age": {YEAR: 19}, + "current_education": {YEAR: "UPPER_SECONDARY"}, + "age_started_or_accepted_current_education_or_training": {YEAR: 17}, + "is_claimant_or_partner": {YEAR: False}, + } + for name in ("first", "second") + }, + } + people["first"][variable] = {YEAR: False} + situation = { + "people": people, + "benunits": {"b": {"members": list(people)}}, + "households": {"h": {"members": list(people)}}, + } + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + uc = sim.calculate(UC, YEAR) + pc = sim.calculate(PC, YEAR) + # The second student is a qualifying young person for both. + assert uc[2] and pc[2], variable + # The first loses the status the variable governs. + if variable == UC_TERMINAL: + assert not uc[1] and pc[1] + elif variable == NINETEEN: + assert not uc[1] and not pc[1] + else: + assert uc[1] and not pc[1] + + +def test_crossed_settings_need_the_programme_variable_for_everyone(): + # Invariant 5's qualification. Two 19-year-olds at school: the first has + # the date fact set false, the second the programme variable set false. + # Set for the second alone, the programme variable gives the first its + # default (true), so the first becomes eligible again for that + # programme; set for both, both are excluded, as the documentation says. + def household(programme_variable, both): + people = { + "pensioner": {"age": {YEAR: 70}, "is_claimant_or_partner": {YEAR: True}}, + **{ + name: { + "age": {YEAR: 19}, + "current_education": {YEAR: "UPPER_SECONDARY"}, + "age_started_or_accepted_current_education_or_training": {YEAR: 17}, + "is_claimant_or_partner": {YEAR: False}, + } + for name in ("first", "second") + }, + } + people["first"][NINETEEN] = {YEAR: False} + people["second"][programme_variable] = {YEAR: False} + if both: + people["first"][programme_variable] = {YEAR: False} + situation = { + "people": people, + "benunits": {"b": {"members": list(people)}}, + "households": {"h": {"members": list(people)}}, + } + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + return Simulation(situation=situation) + + for variable, status in ( + (UC_TERMINAL, UC), + ( + "meets_qualifying_young_person_terminal_date_condition_for_pension_credit", + PC, + ), + ): + partial = household(variable, both=False).calculate(status, YEAR) + # The documented hazard: the first student takes the default. + assert partial[1] and not partial[2], variable + full = household(variable, both=True).calculate(status, YEAR) + assert not full[1] and not full[2], variable diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py index 125702ebd4..2cad11f56f 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py @@ -9,12 +9,12 @@ class is_qualifying_young_person_for_pension_credit(Variable): "A person aged 16 to 19 who is either before the 1 September following " "their 16th birthday, whatever their education (reg. 4A(1)(a)), or in " "non-advanced education or approved training that meets the entry " - "condition (reg. 4A(1)(b), (2) and (3)), and who is not receiving " - "Universal Credit, Employment and Support Allowance, Jobseeker's " - "Allowance or Income Support in their own right (reg. 4A(5)). The " - "own-right flag or their own reported award shows the first three; " - "is_on_income_support shows Income Support. The model does not apply " - "reg. 4A(1)(b)'s end at the 1 September after the 19th birthday." + "condition, before the 1 September following their 19th birthday " + "(reg. 4A(1)(b), (2) and (3)), and who is not receiving Universal " + "Credit, Employment and Support Allowance, Jobseeker's Allowance or " + "Income Support in their own right (reg. 4A(5)). The own-right flag " + "or their own reported award shows the first three; " + "is_on_income_support shows Income Support." ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" @@ -25,12 +25,19 @@ def formula(person, period, parameters): "is_before_first_september_following_16th_birthday", period ) # Reg. 4A(1)(b), (2) and (3). - in_education_or_training = person( - "meets_qualifying_young_person_education_or_training_condition_for_pension_credit", - period, - ) & person( - "meets_qualifying_young_person_entry_condition_for_pension_credit", - period, + in_education_or_training = ( + person( + "meets_qualifying_young_person_education_or_training_condition_for_pension_credit", + period, + ) + & person( + "meets_qualifying_young_person_entry_condition_for_pension_credit", + period, + ) + & person( + "meets_qualifying_young_person_terminal_date_condition_for_pension_credit", + period, + ) ) # Reg. 4A(5) applies to both routes. Raw person-level reports identify # own awards without calculating family benefits, which depend on QYP diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py new file mode 100644 index 0000000000..2a65f21606 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class meets_qualifying_young_person_terminal_date_condition_for_pension_credit( + Variable +): + value_type = bool + entity = Person + label = "Meets qualifying young person terminal date condition for Pension Credit" + documentation = ( + "A young person in education or training is a qualifying young person " + "up to, but not including, the 1 September following their 19th " + "birthday (SPC Regs 2002 reg. 4A(1)(b)). Under 19 that date is always " + "ahead; at 19 it is read from " + "is_before_first_september_following_19th_birthday. Its default is " + "true, the value the formula gives everyone under 20 on 6 October, so " + "setting it for some people leaves everyone else under 20 where the " + "formula puts them; older people are excluded by the age limit. " + "Everyone else takes the default, not their date fact: a person whose " + "is_before_first_september_following_19th_birthday is set false " + "becomes eligible again once this variable is set for someone else. " + "So when setting both, set this one for every person whose status " + "matters." + ) + definition_period = YEAR + default_value = True + reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.pension_credit.guarantee_credit.child.eligibility + age = person("age", period) + return (age < p.terminal_date_age_limit) | person( + "is_before_first_september_following_19th_birthday", period + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py index b9f6e4a784..0c79bf170d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py @@ -6,11 +6,28 @@ class is_before_universal_credit_qualifying_young_person_terminal_date(Variable) entity = Person label = "Before the Universal Credit qualifying young person terminal date" documentation = ( - "Whether this person is before the Universal Credit terminal date " - "for a 19-year-old qualifying young person. This captures date-of-birth " - "and assessment-period detail that is not otherwise available in the " - "annual model." + "Whether this person is before the Universal Credit terminal date for " + "a 19-year-old qualifying young person: the 1 September following " + "their 19th birthday (UC Regs 2013 reg. 5(1)(b)). It follows the date " + "of birth (is_before_first_september_following_19th_birthday), so a " + "year counts if it starts before that date. Datasets and situations " + "can set it directly: set it to false for a 19-year-old whose 1 " + "September has passed. Its default is true, the value the date fact " + "gives everyone under 20 on 6 October, so setting it for some people " + "leaves everyone else under 20 where the date fact puts them; older " + "people are excluded by the age limit before it is read. Everyone " + "else takes the default, not their date fact: a person whose " + "is_before_first_september_following_19th_birthday is set false " + "becomes eligible again once this variable is set for someone else. " + "So when setting both, set this one for every person whose status " + "matters." ) definition_period = YEAR - default_value = False - reference = "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders" + default_value = True + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders", + ) + + def formula(person, period, parameters): + return person("is_before_first_september_following_19th_birthday", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py index 73837e7b14..9ab4e6a4f5 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py @@ -7,8 +7,19 @@ class meets_qualifying_young_person_terminal_date_condition_for_universal_credit value_type = bool entity = Person label = "Meets qualifying young person terminal date condition for Universal Credit" + documentation = ( + "A young person in education or training is a qualifying young person " + "up to, but not including, the 1 September following their 19th " + "birthday (UC Regs 2013 reg. 5(1)(b)). Under 19 that date is always " + "ahead; at 19 it is read from " + "is_before_universal_credit_qualifying_young_person_terminal_date, " + "which follows the date of birth unless set." + ) definition_period = YEAR - reference = "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders" + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders", + ) def formula(person, period, parameters): p = parameters(period).gov.dwp.universal_credit.elements.child.eligibility diff --git a/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py b/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py new file mode 100644 index 0000000000..6d30c8ad83 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py @@ -0,0 +1,52 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day + + +class is_before_first_september_following_19th_birthday(Variable): + value_type = bool + entity = Person + label = ( + "Before the 1 September following the 19th birthday at the start of the year" + ) + documentation = ( + "Whether the fiscal year starts (6 April) before the 1 September " + "following the person's 19th birthday, the date to which a young " + "person in non-advanced education or approved training stays a " + "qualifying young person (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 " + "reg. 4A(1)(b)). The date comes from date_of_birth, or from age and " + "months_since_last_birthday. A 19th birthday on 31 August ends the " + "period that day, and one on 1 September runs it to the 20th " + "birthday. The period ends on a fixed 1 September that annual " + "periods cannot split, so a person in it for part of the year counts " + "for the year, as is_before_first_september_following_16th_birthday " + "does. Everyone aged 19 on 6 October is before the date. So is a " + "20-year-old born from 1 September to 6 October, whom the qualifying " + "young person age limit, read on 6 October, already excludes. So the " + "formula gives true for everyone under 20 on 6 October, which is " + "everyone that limit lets be a qualifying young person, and the " + "default is true: a situation that sets this for some people gives " + "everyone else true. That is the formula's value for anyone under 20; " + "for someone older it is not, but every reader of this variable is " + "behind the qualifying young person age limit, which excludes them " + "first. Set it to false for a person whose 1 September has passed, or " + "set their date_of_birth." + ) + definition_period = YEAR + default_value = True + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A", + ) + + def formula(person, period, parameters): + date_of_birth = birth_day(person, period) + # The 19th birthday falls in the birth year plus 19; the 1 September + # following it is in that year if the birthday is before 1 September, + # and otherwise in the next. + born_on_or_after_first_september = date_of_birth % 10_000 >= 901 + year_of_first_september = ( + date_of_birth // 10_000 + 19 + born_on_or_after_first_september + ) + # The year starts on 6 April, before 1 September of the same calendar + # year and after 1 September of the one before. + return year_of_first_september >= period.start.year