From e64fc90a8ce781ad98886c18612b3ad693d3cd29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:39:37 +0100 Subject: [PATCH 1/3] Stop funded early years hours once a child has started school A council meets the universal and working parent duties for a child in a state-funded or council-funded reception class through the school place: DfE's statutory guidance (April 2026, footnotes 7 and 17, paras A4.8 and C.6) gives such a child no free hours outside it. In Scotland a child in primary 1 is no longer an eligible pre-school child (Children and Young People (Scotland) Act 2014 s.47(2)(b)). early_years_child_in_school is true when current_education is primary or a later stage, and can be set directly. Universal eligibility, the working parent qualifying child and Scottish funded early learning and childcare leave such a child out. Without a current_education input a 4-year-old is not in school, so situations without one are unchanged. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2567.fixed.md | 1 + .../dfe/extended-childcare-entitlement.ipynb | 2 + .../dfe/universal-childcare-entitlement.ipynb | 1 + ...tended_childcare_entitlement_eligible.yaml | 68 +++++++++++++++++++ .../universal_childcare_entitlement.yaml | 57 +++++++++++++++- ...versal_childcare_entitlement_eligible.yaml | 33 +++++++++ .../funded_early_learning_childcare.yaml | 37 ++++++++++ .../early_years_child_in_school.yaml | 48 +++++++++++++ ..._childcare_entitlement_floor_properties.py | 51 +++++++++++++- ..._childcare_entitlement_qualifying_child.py | 14 +++- ...niversal_childcare_entitlement_eligible.py | 16 ++++- ...unded_early_learning_childcare_eligible.py | 13 +++- .../early_years_child_in_school.py | 43 ++++++++++++ 13 files changed, 376 insertions(+), 8 deletions(-) create mode 100644 changelog.d/2567.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/household/demographic/early_years_child_in_school.yaml create mode 100644 policyengine_uk/variables/household/demographic/early_years_child_in_school.py diff --git a/changelog.d/2567.fixed.md b/changelog.d/2567.fixed.md new file mode 100644 index 000000000..595c5f90c --- /dev/null +++ b/changelog.d/2567.fixed.md @@ -0,0 +1 @@ +- Stop the universal and working parent free hours in England, and Scotland's funded early learning and childcare, once a child has started school. A council meets both English duties for a child in a state-funded or council-funded reception class through the school place (DfE statutory guidance, footnotes 7 and 17, paras A4.8 and C.6), and a child in primary 1 is no longer an eligible pre-school child in Scotland (Children and Young People (Scotland) Act 2014 s.47(2)(b)). The new `early_years_child_in_school` is true when `current_education` is primary or a later stage, so datasets' 4-year-olds in reception no longer get funded hours; without a `current_education` input nothing changes. Set it to false for a fee-paying independent school's reception child, who can still take funded hours. diff --git a/docs/book/programs/gov/dfe/extended-childcare-entitlement.ipynb b/docs/book/programs/gov/dfe/extended-childcare-entitlement.ipynb index 3bde2a505..acaeeb840 100644 --- a/docs/book/programs/gov/dfe/extended-childcare-entitlement.ipynb +++ b/docs/book/programs/gov/dfe/extended-childcare-entitlement.ipynb @@ -23,6 +23,8 @@ "\n", "The 30 hours include any universal (3- and 4-year-old) or targeted (2-year-old) hours available to the child. [Childcare Act 2016 s.1(6)](https://www.legislation.gov.uk/ukpga/2016/5/section/1) counts childcare available under the 2006 Act towards the total; [2014 regulations 3–4](https://www.legislation.gov.uk/uksi/2014/2147/made/data.html) establish those base entitlements. DfE's [statutory guidance A1.11](https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026#section-a1-eligibility) requires the first 15 hours for a child eligible for both targeted and working-parent provision to be funded as targeted hours. Qualifying for the working-parent entitlement never removes universal or targeted eligibility.\n", "\n", + "A child in a state-funded or council-funded reception class gets no funded hours outside its school place ([statutory guidance](https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026), footnotes 7 and 17, paras A4.8 and C.6), so it gets neither universal nor working-parent hours once `current_education` is primary or a later stage (`early_years_child_in_school`).\n", + "\n", "### Eligibility requirements\n", "\n", "#### Work status\n", diff --git a/docs/book/programs/gov/dfe/universal-childcare-entitlement.ipynb b/docs/book/programs/gov/dfe/universal-childcare-entitlement.ipynb index 20320d8da..22a7b12d4 100644 --- a/docs/book/programs/gov/dfe/universal-childcare-entitlement.ipynb +++ b/docs/book/programs/gov/dfe/universal-childcare-entitlement.ipynb @@ -48,6 +48,7 @@ "* Age requirements:\n", " * Checks child meets minimum age requirement\n", " * Ensures child is below compulsory school age\n", + " * Excludes a child who has started school, such as in a reception class, whose place meets the entitlement ([DfE statutory guidance](https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026), footnotes 7 and 17): `early_years_child_in_school`, true when `current_education` is primary or a later stage\n", " * Referenced in Section 7 of Childcare Act 2006\n", "\n", "Example table:\n", diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.yaml index 616204737..7e6ceb549 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.yaml @@ -178,6 +178,74 @@ output: extended_childcare_entitlement_eligible: false +# DfE's statutory guidance (April 2026, footnotes 7 and 17, paras A4.8 and +# C.6): a child in a state-funded or council-funded reception class is not +# entitled to free hours outside the school place, though it is still under +# compulsory school age. +- name: Not eligible - the only child is in a reception class + period: 2026 + input: + households: + household1: + members: [parent1, child1] + country: ENGLAND + people: + parent1: + age: 35 + extended_childcare_entitlement_work_condition: true + child1: + age: 4 + current_education: PRIMARY + benunits: + benunit1: + members: [parent1, child1] + output: + extended_childcare_entitlement_qualifying_child: [false, false] + extended_childcare_entitlement_eligible: false + +- name: Eligible - a child in a reception class with a 2-year-old sibling + period: 2026 + input: + households: + household1: + members: [parent1, child1, child2] + country: ENGLAND + people: + parent1: + age: 35 + extended_childcare_entitlement_work_condition: true + child1: + age: 4 + current_education: PRIMARY + child2: + age: 2 + benunits: + benunit1: + members: [parent1, child1, child2] + output: + extended_childcare_entitlement_qualifying_child: [false, false, true] + extended_childcare_entitlement_eligible: true + +- name: Eligible - a 4-year-old at nursery + period: 2026 + input: + households: + household1: + members: [parent1, child1] + country: ENGLAND + people: + parent1: + age: 35 + extended_childcare_entitlement_work_condition: true + child1: + age: 4 + current_education: PRE_PRIMARY + benunits: + benunit1: + members: [parent1, child1] + output: + extended_childcare_entitlement_eligible: true + - name: Not eligible - no children period: 2025 input: diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.yaml index 9176525d1..a2457482c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.yaml @@ -120,4 +120,59 @@ universal_childcare_entitlement_eligible: True max_free_entitlement_hours_used: 20 output: - universal_childcare_entitlement: 3_351.6 # Capped at 15 hours * 38 weeks * £5.88 per hour \ No newline at end of file + universal_childcare_entitlement: 3_351.6 # Capped at 15 hours * 38 weeks * £5.88 per hour + +# DfE's statutory guidance (April 2026, footnotes 7 and 17, paras A4.8 and +# C.6): a child in a state-funded or council-funded reception class is "not +# entitled to any additional free hours outside their school reception class +# place", under either the universal or the working parent entitlement. +- name: A 4-year-old in a reception class gets no free hours + period: 2026 + absolute_error_margin: 0.05 + input: + people: + parent1: + age: 35 + employment_income: 40_000 + parent2: + age: 35 + employment_income: 30_000 + child: + age: 4 + current_education: PRIMARY + benunits: + benunit: + members: [parent1, parent2, child] + households: + household: + members: [parent1, parent2, child] + country: ENGLAND + output: + universal_childcare_entitlement: [0, 0, 0] + extended_childcare_entitlement_per_child: [0, 0, 0] + +- name: A 4-year-old at nursery keeps the universal and working parent hours + period: 2026 + absolute_error_margin: 0.05 + input: + people: + parent1: + age: 35 + employment_income: 40_000 + parent2: + age: 35 + employment_income: 30_000 + child: + age: 4 + current_education: PRE_PRIMARY + benunits: + benunit: + members: [parent1, parent2, child] + households: + household: + members: [parent1, parent2, child] + country: ENGLAND + output: + # 570 universal hours and 570 working parent hours, at £6.61. + universal_childcare_entitlement: [0, 0, 3_767.70] + extended_childcare_entitlement_per_child: [0, 0, 3_767.70] \ No newline at end of file diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.yaml index 0caaa4c8f..f8b3fe95a 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.yaml @@ -38,6 +38,39 @@ output: universal_childcare_entitlement_eligible: True +# DfE's statutory guidance (April 2026, footnotes 7 and 17, paras A4.8 and +# C.6): a child in a state-funded or council-funded reception class is not +# entitled to free hours outside the school place. +- name: Child aged 4 in a reception class in England is not eligible + period: 2026 + input: + age: 4 + current_education: PRIMARY + country: ENGLAND + output: + universal_childcare_entitlement_eligible: false + +- name: Child aged 4 at nursery in England is eligible + period: 2026 + input: + age: 4 + current_education: PRE_PRIMARY + country: ENGLAND + output: + universal_childcare_entitlement_eligible: true + +# A fee-paying independent school's reception class is recorded as primary +# education, but its children can take funded hours there. +- name: Child aged 4 in a reception class not counted as in school stays eligible + period: 2026 + input: + age: 4 + current_education: PRIMARY + early_years_child_in_school: false + country: ENGLAND + output: + universal_childcare_entitlement_eligible: true + # Childcare Act 2016 s.1(6)(a): the universal hours count towards the working # parent's 30 hours, so eligibility for that entitlement does not remove them. - name: Child aged 3 in a family eligible for the working parent entitlement stays eligible diff --git a/policyengine_uk/tests/policy/baseline/gov/scottish_government/funded_early_learning_childcare.yaml b/policyengine_uk/tests/policy/baseline/gov/scottish_government/funded_early_learning_childcare.yaml index 96ac7f211..4da8762d7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/scottish_government/funded_early_learning_childcare.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/scottish_government/funded_early_learning_childcare.yaml @@ -98,3 +98,40 @@ region: SCOTLAND output: scottish_funded_early_learning_childcare_eligible: false + +# Children and Young People (Scotland) Act 2014 s.47(2)(b): an eligible +# pre-school child "has not commenced attendance at a primary school (other +# than at a nursery class in such a school)". +- name: Scottish 4-year-old in primary 1 is not eligible + period: 2026 + input: + people: + child: + age: 4 + current_education: PRIMARY + benunits: + benunit: + members: [child] + households: + household: + members: [child] + region: SCOTLAND + output: + scottish_funded_early_learning_childcare_eligible: false + +- name: Scottish 4-year-old in a nursery class is eligible + period: 2026 + input: + people: + child: + age: 4 + current_education: PRE_PRIMARY + benunits: + benunit: + members: [child] + households: + household: + members: [child] + region: SCOTLAND + output: + scottish_funded_early_learning_childcare_eligible: true diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/early_years_child_in_school.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/early_years_child_in_school.yaml new file mode 100644 index 000000000..25cfe5c1f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/demographic/early_years_child_in_school.yaml @@ -0,0 +1,48 @@ +# A child has started school once current_education is primary or a later +# stage; a 4- or 5-year-old in full-time education can be recorded at a later +# stage than primary. +- name: A 4-year-old is in school at primary or any later stage of education + period: 2026 + input: + people: + not_in_education: + age: 4 + current_education: NOT_IN_EDUCATION + nursery: + age: 4 + current_education: PRE_PRIMARY + not_completed_primary: + age: 4 + current_education: NOT_COMPLETED_PRIMARY + reception: + age: 4 + current_education: PRIMARY + lower_secondary: + age: 4 + current_education: LOWER_SECONDARY + upper_secondary: + age: 4 + current_education: UPPER_SECONDARY + post_secondary: + age: 4 + current_education: POST_SECONDARY + tertiary: + age: 4 + current_education: TERTIARY + output: + early_years_child_in_school: [false, false, false, true, true, true, true, true] + +# The age default for current_education is pre-primary below 4 and "not +# completed primary" from 4 to 6, so without an input no 3- or 4-year-old is +# in school. +- name: Without a current_education input no 3- or 4-year-old is in school + period: 2026 + input: + people: + child_aged_3: + age: 3 + child_aged_4: + age: 4 + output: + current_education: [PRE_PRIMARY, NOT_COMPLETED_PRIMARY] + early_years_child_in_school: [false, false] diff --git a/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py b/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py index 0e77a0a7f..c4279a786 100644 --- a/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py +++ b/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py @@ -49,6 +49,9 @@ targeted floor is at most 15 hours a week. 10. Outside England no child gets universal or working parent hours (Childcare Act 2006 s.7 and 2016 s.1(2)(b) are duties for England). +11. A child in school (current_education primary, as in a reception class) + gets no universal or working parent hours: its school place meets both + duties (guidance footnotes 7 and 17, paras A4.8 and C.6). A further property runs the real eligibility test: raising the £100,000 income limit never lowers childcare support or household net income, and the @@ -92,11 +95,18 @@ def close(a, b): return abs(a - b) <= 0.02 + 1e-6 * abs(b) +def in_school(c): + """Invariant 11: enrolled in primary education, as in a reception class.""" + return c.get("education") == "PRIMARY" + + child = st.fixed_dictionaries( { "age": st.sampled_from([0, 0.5, 0.75, 1, 2, 2.5, 3, 4, 4.9, 5, 6]), "hours_used": st.sampled_from([0, 5, 10, 14.9, 15, 15.1, 20, 30, 45]), "looked_after": st.booleans(), + # None leaves current_education to its age default. + "education": st.sampled_from([None, None, "PRE_PRIMARY", "PRIMARY"]), } ) family = st.fixed_dictionaries( @@ -136,6 +146,10 @@ def simulate(families, year, eligible, cap_bump=0, hours_bump=0): }, "is_looked_after_by_local_authority": {year: c["looked_after"]}, } + if c.get("education") is not None: + situation["people"][name]["current_education"] = { + year: c["education"] + } members.append(name) situation["benunits"][f"b{copy}_{i}"] = { "members": members, @@ -190,7 +204,7 @@ def reference(c, f, year, eligible): in_england = f["england"] universal_hours = ( min(c["hours_used"] * weeks, p.universal_childcare_entitlement.hours) - if in_england and 3 <= age < 5 + if in_england and 3 <= age < 5 and not in_school(c) else 0 ) targeted_hours = ( @@ -211,6 +225,7 @@ def reference(c, f, year, eligible): in_england and under_school_age and not c["looked_after"] + and not in_school(c) and age >= p.extended_childcare_entitlement.young_child_minimum_age ) weekly = ( @@ -409,6 +424,29 @@ def test_working_parent_hours_schedule_at_rollout_boundaries( }, ], ) +@example( + 2026, + [ + # A 4-year-old in a reception class and one at nursery, with a + # 2-year-old sibling. + { + "children": [ + { + "age": age, + "hours_used": 30, + "looked_after": False, + "education": education, + } + for age, education in ((4, "PRIMARY"), (4, "PRE_PRIMARY"), (2, None)) + ], + "cap": 30, + "claims_universal": True, + "claims_targeted": True, + "targeted": False, + "england": True, + }, + ], +) def test_becoming_eligible_never_lowers_childcare_support(year, families): values = simulate(families, year, eligible=[False, True]) not_eligible, eligible = split(values, families, 2) @@ -421,8 +459,17 @@ def test_becoming_eligible_never_lowers_childcare_support(year, families): assert close(copy[0]["family_extended"], per_child), (year, f, copy) for c, b, a in zip(f["children"], before, after): assert funded(a) >= funded(b) - 0.02, (year, f, c, b, a) - legal_universal = f["england"] and 3 <= c["age"] < 5 + legal_universal = f["england"] and 3 <= c["age"] < 5 and not in_school(c) assert a["universal_eligible"] == b["universal_eligible"] == legal_universal + # 11. A school place, not funded hours. + if in_school(c): + for copy in (a, b): + assert copy["universal_childcare_entitlement"] == 0, (year, f, c) + assert copy["extended_childcare_entitlement_per_child"] == 0, ( + year, + f, + c, + ) # 8-10. Statutory maximum, no double floor, England only. assert_statutory_bounds(c, f, f"{year}-01-01", False, b) assert_statutory_bounds(c, f, f"{year}-01-01", True, a) diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_qualifying_child.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_qualifying_child.py index 8692fd336..30eef25cc 100644 --- a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_qualifying_child.py +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_qualifying_child.py @@ -10,12 +10,15 @@ class extended_childcare_entitlement_qualifying_child(Variable): "childcare entitlement: under compulsory school age and in England " "(Childcare Act 2016 s.1(2)(a)-(b)), and at least the minimum age and " "not looked after by a local authority (SI 2022/1134 reg 13(2); " - "SI 2016/1257 reg 3(1) before 1 December 2022)." + "SI 2016/1257 reg 3(1) before 1 December 2022). A child in a " + "reception class does not count: its school place meets the " + "entitlement (early_years_child_in_school)." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/ukpga/2016/5/section/1", "https://www.legislation.gov.uk/uksi/2022/1134/regulation/13", + "https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026", ) def formula(person, period, parameters): @@ -42,9 +45,18 @@ def formula(person, period, parameters): ) not_looked_after = ~person("is_looked_after_by_local_authority", period) + # A child in a state-funded or council-funded reception class is + # still under compulsory school age, but the school place meets the + # duty: DfE's statutory guidance (April 2026, footnotes 7 and 17, + # paras A4.8 and C.6) gives no free hours outside it. Childcare Act + # 2016 s.1(6) splits a child's hours between this entitlement and the + # universal one, so both leave it out. + in_school = person("early_years_child_in_school", period) + return ( under_compulsory_school_age & in_england & meets_minimum_age & not_looked_after + & ~in_school ) diff --git a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.py b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.py index 1cf175e38..5ec9b2a64 100644 --- a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.py +++ b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.py @@ -6,12 +6,17 @@ class universal_childcare_entitlement_eligible(Variable): entity = Person label = "eligible for universal childcare entitlement" definition_period = YEAR - documentation = "Legal eligibility, independent of standalone take-up." + documentation = ( + "Legal eligibility, independent of standalone take-up. A child in a " + "reception class gets no universal hours: its school place meets the " + "entitlement (early_years_child_in_school)." + ) reference = ( "https://www.legislation.gov.uk/ukpga/2006/21/section/7", "https://www.legislation.gov.uk/ukpga/2016/5/section/1", "https://www.legislation.gov.uk/uksi/2014/2147/regulation/3", "https://www.legislation.gov.uk/uksi/2014/2147/regulation/4", + "https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026", ) def formula(person, period, parameters): @@ -33,4 +38,11 @@ def formula(person, period, parameters): # Section 7 of the Childcare Act 2006 # The regulation above limits free early years provision to children under compulsory school age. - return in_england & meets_age_condition & not_compulsory_age + + # The duty lasts until compulsory school age, but a council meets it + # for a child in a state-funded or council-funded reception class + # through the school place: DfE's statutory guidance (April 2026, + # footnotes 7 and 17, paras A4.8 and C.6) gives such a child no free + # hours outside it. + in_school = person("early_years_child_in_school", period) + return in_england & meets_age_condition & not_compulsory_age & ~in_school diff --git a/policyengine_uk/variables/gov/scottish_government/funded_early_learning_childcare/scottish_funded_early_learning_childcare_eligible.py b/policyengine_uk/variables/gov/scottish_government/funded_early_learning_childcare/scottish_funded_early_learning_childcare_eligible.py index 410cc4856..bb2a1d8f9 100644 --- a/policyengine_uk/variables/gov/scottish_government/funded_early_learning_childcare/scottish_funded_early_learning_childcare_eligible.py +++ b/policyengine_uk/variables/gov/scottish_government/funded_early_learning_childcare/scottish_funded_early_learning_childcare_eligible.py @@ -11,10 +11,12 @@ class scottish_funded_early_learning_childcare_eligible(Variable): "not yet model the eligible-two-year-old strand (vulnerable / low-income " "children aged 2 also qualify under Part 2 of the 2014 Order). " "Three-year-olds qualify only once they have reached the statutory " - "start date." + "start date. A child who has started primary 1 no longer qualifies " + "(early_years_child_in_school)." ) definition_period = YEAR reference = [ + "https://www.legislation.gov.uk/asp/2014/8/section/47", "https://www.legislation.gov.uk/asp/2014/8/section/48", "https://www.gov.scot/publications/early-learning-childcare-statutory-guidance-july-2021/pages/8/", ] @@ -35,4 +37,11 @@ def formula(person, period, parameters): ) eligible_older_child = (age >= p.age.min + 1) & (age < p.age.max) - return in_scotland & (eligible_three_year_old | eligible_older_child) + # Children and Young People (Scotland) Act 2014 s.47(2)(b): an + # eligible pre-school child "has not commenced attendance at a + # primary school (other than at a nursery class in such a school)". + in_school = person("early_years_child_in_school", period) + + return ( + in_scotland & (eligible_three_year_old | eligible_older_child) & ~in_school + ) diff --git a/policyengine_uk/variables/household/demographic/early_years_child_in_school.py b/policyengine_uk/variables/household/demographic/early_years_child_in_school.py new file mode 100644 index 000000000..de8a1ebab --- /dev/null +++ b/policyengine_uk/variables/household/demographic/early_years_child_in_school.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +# Primary education or a later stage. A 4- or 5-year-old in full-time +# education can be recorded at a later stage than primary, so all of them +# count. +SCHOOL_STAGES = ( + EducationType.PRIMARY, + EducationType.LOWER_SECONDARY, + EducationType.UPPER_SECONDARY, + EducationType.POST_SECONDARY, + EducationType.TERTIARY, +) + + +class early_years_child_in_school(Variable): + value_type = bool + entity = Person + label = "early years child in school" + documentation = ( + "Whether a child of early years age has started school: " + "current_education is primary or a later stage. In England that is " + "a reception class, whose place meets the universal and working " + "parent entitlements, so no funded hours are given outside it. In " + "Scotland it is primary 1, after which a child is no longer an " + "eligible pre-school child for funded early learning and childcare. " + "Without a current_education input a 4-year-old is not in school, " + "since the age default for 4- to 6-year-olds is " + "NOT_COMPLETED_PRIMARY. Primary includes fee-paying independent " + "schools, whose reception children can still take funded hours: set " + "this to false for them." + ) + definition_period = YEAR + reference = ( + "https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026", + "https://www.legislation.gov.uk/asp/2014/8/section/47", + ) + + def formula(person, period, parameters): + education = person("current_education", period) + return np.logical_or.reduce([education == stage for stage in SCHOOL_STAGES]) From 369f5503763ed908a3981596c1d4c83d48a14cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:56:36 +0100 Subject: [PATCH 2/3] Start working parent hours with the term after the minimum age SI 2022/1134 reg 27(1)(a): a child is eligible from the first 1 January, 1 April or 1 September after the day its declaration has effect, which for a declaration made in advance is the day it attains the reg 13 minimum age (regs 20(a) and 25(1)(b)). SI 2016/1257 reg 35(3) set the same dates from the third birthday before December 2022. The model tested the age once, in the middle of the year, so no child aged 0 then qualified and every child aged 1 qualified all year. extended_childcare_entitlement_term_share is the share of the year's terms, beginning on 1 April, 1 September and 1 January, that begin after the child attains the minimum age, read from the date of birth. The qualifying child needs a share above 0, the working parent total is scaled by it, and universal and targeted hours meet it term by term. With a share of 1 every formula is unchanged. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2567-term-starts.fixed.md | 1 + .../dfe/extended-childcare-entitlement.ipynb | 1 + ...ended_childcare_entitlement_per_child.yaml | 90 +++++++++++++++ ...nded_childcare_entitlement_term_share.yaml | 105 +++++++++++++++++ ..._childcare_entitlement_floor_properties.py | 109 +++++++++++++++--- .../test_extended_childcare_term_share.py | 76 ++++++++++++ ...xtended_childcare_entitlement_per_child.py | 21 +++- ..._childcare_entitlement_qualifying_child.py | 19 +-- ...tended_childcare_entitlement_term_share.py | 51 ++++++++ ...d_childcare_entitlement_total_per_child.py | 18 ++- .../is_child_receiving_extended_childcare.py | 6 +- .../targeted_childcare_entitlement.py | 12 +- .../universal_childcare_entitlement.py | 12 +- 13 files changed, 479 insertions(+), 42 deletions(-) create mode 100644 changelog.d/2567-term-starts.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_term_share.yaml create mode 100644 policyengine_uk/tests/test_extended_childcare_term_share.py create mode 100644 policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_term_share.py diff --git a/changelog.d/2567-term-starts.fixed.md b/changelog.d/2567-term-starts.fixed.md new file mode 100644 index 000000000..9dad21e25 --- /dev/null +++ b/changelog.d/2567-term-starts.fixed.md @@ -0,0 +1 @@ +- Start a child's working parent hours with the first 1 January, 1 April or 1 September after it reaches the minimum age (SI 2022/1134 reg 27(1)(a); SI 2016/1257 reg 35(3) before December 2022), each of the year's terms counting for a third of it. The new `extended_childcare_entitlement_term_share` reads the date of birth, so a child aged 0 in the middle of the year who was born from October to March now gets one or two terms' hours, where none did before, and a 1-year-old who reached nine months after 1 April loses the summer term. Universal and targeted hours meet the working parent hours term by term. From 2025, household simulations that give only whole ages are unchanged, since such a child is taken to be born on 6 April; in earlier years, at a minimum age of two or three, such a child now qualifies from September. diff --git a/docs/book/programs/gov/dfe/extended-childcare-entitlement.ipynb b/docs/book/programs/gov/dfe/extended-childcare-entitlement.ipynb index acaeeb840..5069296ff 100644 --- a/docs/book/programs/gov/dfe/extended-childcare-entitlement.ipynb +++ b/docs/book/programs/gov/dfe/extended-childcare-entitlement.ipynb @@ -126,6 +126,7 @@ "### 5. Entitlement calculation\n", "Final calculations in [`extended_childcare_entitlement_per_child.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py), summed for the family by [`extended_childcare_entitlement.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement.py):\n", "* Determines weekly hours based on child's age\n", + "* Counts only the terms the child is old enough for (`extended_childcare_entitlement_term_share`): its hours start with the first 1 January, 1 April or 1 September after it reaches the minimum age ([SI 2022/1134 reg 27](https://www.legislation.gov.uk/uksi/2022/1134/regulation/27)). The year's terms begin on 1 April, 1 September and 1 January, a third of the year each, so a child aged 0 in the middle of the year can get one or two terms, and a child aged 1 who reached nine months after 1 April misses the summer term. Survey ages are whole years; `months_since_last_birthday` spreads birthdays over the year, and in a household simulation a child given only a whole age is taken to be born on 6 April\n", "* Limits them to the hours the child and the family use (`max_free_entitlement_hours_used`, `maximum_extended_childcare_hours_usage`)\n", "* Applies appropriate funding rates\n", "* For children participating in working-parent provision, allocates their first funded hours, up to 15 per week, to the applicable universal or targeted entitlement based on legal eligibility, regardless of the standalone take-up flags\n", diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.yaml index 9397df199..dd87f1816 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.yaml @@ -54,6 +54,96 @@ # 30 x 38 x 12.04 extended_childcare_entitlement_per_child: 13725.60 +# SI 2022/1134 reg 27(1)(a): working parent hours start with the first +# 1 January, 1 April or 1 September after the child is nine months old, each +# term a third of the year. +- name: A baby nine months old by 1 January gets the spring term's hours + period: 2026 + absolute_error_margin: 0.05 + input: + people: + child: + age: 0 + date_of_birth: 20260315 + benunits: + benunit: + members: [child] + extended_childcare_entitlement_eligible: true + output: + # Nine months on 15 December 2026: a third of 30 x 38 x 12.04. + extended_childcare_entitlement_per_child: 4575.20 + is_child_receiving_extended_childcare: true + +- name: A baby nine months old by 1 September gets two terms' hours + period: 2026 + absolute_error_margin: 0.05 + input: + people: + child: + age: 0 + date_of_birth: 20251115 + benunits: + benunit: + members: [child] + extended_childcare_entitlement_eligible: true + output: + # Nine months on 15 August 2026. + extended_childcare_entitlement_per_child: 9150.40 + +- name: A 1-year-old nine months old after 1 April misses the summer term + period: 2026 + absolute_error_margin: 0.05 + input: + people: + child: + age: 1 + date_of_birth: 20250720 + benunits: + benunit: + members: [child] + extended_childcare_entitlement_eligible: true + output: + # Nine months on 20 April 2026. + extended_childcare_entitlement_per_child: 9150.40 + +# Until April 2024 the minimum age was three (reg 13(2)(a) as made). A +# standalone universal claim runs all year; the working parent hours top it up +# to 30 a week only in the terms after the third birthday. Rate: £5.88 (3-4). +- name: A 3-year-old qualifying from September keeps a full year of universal hours + period: 2023 + absolute_error_margin: 0.05 + input: + people: + child: + age: 3 + date_of_birth: 20200801 + benunits: + benunit: + members: [child] + extended_childcare_entitlement_eligible: true + output: + # 570 x 5.88, then 15 more hours a week in two of the three terms. + universal_childcare_entitlement: 3351.60 + extended_childcare_entitlement_per_child: 2234.40 + +- name: Without standalone take-up such a child's universal hours come only in those terms + period: 2023 + absolute_error_margin: 0.05 + input: + people: + child: + age: 3 + date_of_birth: 20200801 + benunits: + benunit: + members: [child] + extended_childcare_entitlement_eligible: true + would_claim_universal_childcare: false + output: + # Two thirds of 570 x 5.88 in each programme. + universal_childcare_entitlement: 2234.40 + extended_childcare_entitlement_per_child: 2234.40 + - name: Working parent participation funds universal first even with false standalone take-up period: 2026 absolute_error_margin: 0.05 diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_term_share.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_term_share.yaml new file mode 100644 index 000000000..8ac5f54c6 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_term_share.yaml @@ -0,0 +1,105 @@ +# SI 2022/1134 reg 27(1)(a): a child is eligible for working parent hours +# from the first 1 January, 1 April or 1 September after the day its +# declaration has effect, which for a declaration made in advance is the day +# it attains the minimum age (regs 13(2)(a), 20(a) and 25(1)): nine months +# from 1 September 2024 (SI 2023/1330 reg 2(5)(b)). The model year runs from +# April, so its terms begin on 1 April, 1 September and 1 January. +- name: Nine months before 1 January gives the spring term only + period: 2026 + input: + age: 0 + date_of_birth: 20260331 + output: + # Nine months on 31 December 2026. + extended_childcare_entitlement_term_share: 0.3333 + absolute_error_margin: 0.0001 + +- name: Nine months on 1 January gives no term in the year + period: 2026 + input: + age: 0 + date_of_birth: 20260401 + output: + # Nine months on 1 January 2027: eligible from 1 April 2027. + extended_childcare_entitlement_term_share: 0 + extended_childcare_entitlement_qualifying_child: false + +- name: Nine months before 1 September gives the autumn and spring terms + period: 2026 + input: + age: 0 + date_of_birth: 20251130 + output: + # Nine months on 30 August 2026. + extended_childcare_entitlement_term_share: 0.6667 + absolute_error_margin: 0.0001 + +- name: Nine months on 1 September gives the spring term only + period: 2026 + input: + age: 0 + date_of_birth: 20251201 + output: + extended_childcare_entitlement_term_share: 0.3333 + absolute_error_margin: 0.0001 + +- name: A 1-year-old who reached nine months on 1 April misses the summer term + period: 2026 + input: + age: 1 + date_of_birth: 20250701 + output: + extended_childcare_entitlement_term_share: 0.6667 + absolute_error_margin: 0.0001 + +- name: A 1-year-old who reached nine months before 1 April gets every term + period: 2026 + input: + age: 1 + date_of_birth: 20250630 + output: + extended_childcare_entitlement_term_share: 1 + +# Without a date of birth a whole age is the middle of the year of age: born +# on 6 April, so nine months old on 6 January. +- name: Without a date of birth, age 0 gets no term and age 1 every term + period: 2026 + input: + people: + baby: + age: 0 + toddler: + age: 1 + output: + extended_childcare_entitlement_term_share: [0, 1] + +# A fractional age is the exact age on 6 October. +- name: A child exactly nine months old on 6 October gets the spring term + period: 2026 + input: + age: 0.75 + output: + extended_childcare_entitlement_term_share: 0.3333 + absolute_error_margin: 0.0001 + +# Reg 13(2)(a): "two" from 1 April 2024 to 31 August 2024 (SI 2023/1330 reg +# 2(5)(a)), the value on 30 April 2024, which the model reads for 2024. +- name: A child turning two on 1 April 2024 qualifies from September + period: 2024 + input: + age: 2 + date_of_birth: 20220401 + output: + extended_childcare_entitlement_term_share: 0.6667 + absolute_error_margin: 0.0001 + +# SI 2016/1257 reg 35(3) set the same start dates from the third birthday +# before December 2022. +- name: A child turning three after 1 April 2022 qualifies from September + period: 2022 + input: + age: 3 + date_of_birth: 20190415 + output: + extended_childcare_entitlement_term_share: 0.6667 + absolute_error_margin: 0.0001 diff --git a/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py b/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py index c4279a786..f498d0ce6 100644 --- a/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py +++ b/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py @@ -23,12 +23,13 @@ 2. Legal universal and targeted eligibility does not depend on standalone take-up. A participating child's first funded hours, up to 15 a week, go to the applicable base programme even when its standalone flag is false. -3. Each child's funded value equals an independent scalar reference: - max(claimed standalone value, working parent total) when eligible, the - claimed standalone value otherwise. Programme allocations count the - legally available base hours first, preserving that total. -4. A child's funded hours never exceed the larger of its universal or - targeted hours and its working parent total, so never 30 hours a week +3. Each child's funded value equals an independent scalar reference, term by + term: max(claimed standalone value, working parent total) in the terms + the child is old enough for when eligible (invariant 12), the claimed + standalone value otherwise. Programme allocations count the legally + available base hours first, preserving that total. +4. In no term do a child's funded hours exceed the larger of its universal + or targeted hours and its working parent hours, so never 30 hours a week plus the universal 15. 5. Raising the family's maximum hours used, or a child's own hours used, never lowers support. @@ -52,6 +53,11 @@ 11. A child in school (current_education primary, as in a reception class) gets no universal or working parent hours: its school place meets both duties (guidance footnotes 7 and 17, paras A4.8 and C.6). +12. Working parent hours start with the first 1 January, 1 April or 1 + September after the child attains the minimum age (SI 2022/1134 reg + 27(1)(a)); the year's terms begin on 1 April, 1 September and 1 January, + a third of the year each. A whole age is the middle of the year of age, + so the child was born on 6 April; a fractional age is exact on 6 October. A further property runs the real eligibility test: raising the £100,000 income limit never lowers childcare support or household net income, and the @@ -100,6 +106,25 @@ def in_school(c): return c.get("education") == "PRIMARY" +def months_later(day, months): + """The same day of the month ``months`` calendar months later.""" + index = day.year * 12 + day.month - 1 + months + return date(index // 12, index % 12 + 1, day.day) + + +def term_share(age, year, minimum_age): + """Invariant 12: the share of the year's terms after the child attains + the minimum age.""" + # A whole age is the middle of the year of age; a fractional one is exact + # on 6 October. The drawn ages are whole months, apart from 4.9, which is + # years past any minimum age. + months_old = 12 * age if age != int(age) else 12 * age + 6 + born = months_later(date(year, 10, 6), -round(months_old)) + attains = months_later(born, round(12 * minimum_age)) + starts = (date(year, 4, 1), date(year, 9, 1), date(year + 1, 1, 1)) + return sum(attains < start for start in starts) / len(starts) + + child = st.fixed_dictionaries( { "age": st.sampled_from([0, 0.5, 0.75, 1, 2, 2.5, 3, 4, 4.9, 5, 6]), @@ -221,16 +246,20 @@ def reference(c, f, year, eligible): floor = ( universal_value * f["claims_universal"] + targeted_value * f["claims_targeted"] ) + minimum_age = p.extended_childcare_entitlement.young_child_minimum_age + share = term_share(age, year, minimum_age) qualifying = ( in_england and under_school_age and not c["looked_after"] and not in_school(c) - and age >= p.extended_childcare_entitlement.young_child_minimum_age + and share > 0 ) weekly = ( min( - float(p.extended_childcare_entitlement.hours.calc(age)), + # The hours for its age, or for the minimum age if it reaches + # that later in the year. + float(p.extended_childcare_entitlement.hours.calc(max(age, minimum_age))), c["hours_used"], f["cap"], ) @@ -238,17 +267,22 @@ def reference(c, f, year, eligible): else 0 ) working_parent_total = weekly * weeks * rate if eligible else 0 - total = max(floor, working_parent_total) - # Legal eligibility, independent of standalone take-up, determines - # which programme funds the first hours of participating children. + # In the terms the child is old enough for: the larger of the claimed + # standalone value and the working parent total, with legal eligibility, + # independent of standalone take-up, determining which programme funds + # the first hours. In the other terms: the claimed standalone value. + participating = max(floor, working_parent_total) + total = share * participating + (1 - share) * floor allocations = { - "universal_childcare_entitlement": min(universal_value, total), - "targeted_childcare_entitlement": min(targeted_value, total), + "universal_childcare_entitlement": share * min(universal_value, participating) + + (1 - share) * universal_value * f["claims_universal"], + "targeted_childcare_entitlement": share * min(targeted_value, participating) + + (1 - share) * targeted_value * f["claims_targeted"], } allocations["extended_childcare_entitlement_per_child"] = total - sum( allocations.values() ) - return floor, total, working_parent_total, allocations + return floor, total, participating, allocations def funded(child_values): @@ -447,6 +481,44 @@ def test_working_parent_hours_schedule_at_rollout_boundaries( }, ], ) +@example( + 2026, + [ + # Nine months old on 6 October (eligible from the spring term), and + # whole ages 0 (born 6 April, nine months old on 6 January: no term) + # and 1 (every term). + { + "children": [ + {"age": age, "hours_used": 45, "looked_after": False} + for age in (0.75, 0, 1) + ], + "cap": 60, + "claims_universal": True, + "claims_targeted": True, + "targeted": False, + "england": True, + }, + ], +) +@example( + 2024, + [ + # Two from April 2024: 2-year-olds born on 6 April qualify from + # September, alongside targeted hours claimed on their own or not. + { + "children": [ + {"age": age, "hours_used": 30, "looked_after": False} + for age in (2, 2.5, 1) + ], + "cap": 30, + "claims_universal": True, + "claims_targeted": claims_targeted, + "targeted": True, + "england": True, + } + for claims_targeted in (True, False) + ], +) def test_becoming_eligible_never_lowers_childcare_support(year, families): values = simulate(families, year, eligible=[False, True]) not_eligible, eligible = split(values, families, 2) @@ -477,7 +549,7 @@ def test_becoming_eligible_never_lowers_childcare_support(year, families): # 2-3. Legal base hours fund participating children first, # even with false standalone flags. Each programme and the # combined total match the independent allocation reference. - floor, total_after, working_parent_total, expected_after = reference( + floor, total_after, participating, expected_after = reference( c, f, year, True ) _, total_before, _, expected_before = reference(c, f, year, False) @@ -493,8 +565,9 @@ def test_becoming_eligible_never_lowers_childcare_support(year, families): ) assert close(funded(b), total_before), (year, f, c, b, total_before) assert close(funded(a), total_after), (year, f, c, a, total_after) - # 4. Never both added in full. - assert funded(a) <= max(floor, working_parent_total) + 0.02 + # 4. Never both added in full, even in the terms with working + # parent hours. + assert funded(a) <= participating + 0.02 # 6. Receiving means hours beyond the universal or targeted ones. top_up = a["extended_childcare_entitlement_per_child"] assert a["receiving"] == (top_up > 0), (year, f, c, a) @@ -506,7 +579,7 @@ def test_becoming_eligible_never_lowers_childcare_support(year, families): c, a, floor, - working_parent_total, + participating, ) assert not b["receiving"] diff --git a/policyengine_uk/tests/test_extended_childcare_term_share.py b/policyengine_uk/tests/test_extended_childcare_term_share.py new file mode 100644 index 000000000..efab0f1d9 --- /dev/null +++ b/policyengine_uk/tests/test_extended_childcare_term_share.py @@ -0,0 +1,76 @@ +"""Working parent hours start with the term after the minimum age. + +SI 2022/1134 reg 27(1)(a): a child is eligible for the period "beginning with +whichever of 1st January, 1st April or 1st September is the first date after +the day on which a declaration in respect of the child has effect". A +declaration made in advance has effect from the day the child meets the +description in reg 13 (regs 20(a) and 25(1)): it has attained the minimum +age, nine months from 1 September 2024 (SI 2023/1330 reg 2(5)(b)). Before +December 2022, SI 2016/1257 reg 35(3) set the same dates from the third +birthday. Sources fetched on 2026-10-22: +https://www.legislation.gov.uk/uksi/2022/1134/regulation/27 +https://www.legislation.gov.uk/uksi/2022/1134/regulation/25 +https://www.legislation.gov.uk/uksi/2022/1134/regulation/20 +https://www.legislation.gov.uk/uksi/2016/1257/regulation/35/made + +The model year runs from April, so its terms begin on 1 April and 1 September +of the year and 1 January of the next, a third of the year each. For every +date of birth of a child aged 0 to 4 on 6 October, the share of those terms +that begin after it attains the minimum age matches a reference written with +calendar dates. +""" + +from datetime import date, timedelta + +import pytest + +from policyengine_uk import Simulation + +# The minimum age in months the model reads for each year: the value on 30 +# April (three as made, two from 1 April 2024, nine months from 1 September +# 2024). +MINIMUM_AGE_MONTHS = {2022: 36, 2023: 36, 2024: 24, 2025: 9, 2026: 9} + + +def attains(born, months): + """The day a child born on ``born`` attains an age of ``months`` months: + the same day of the month, or the month's last day if it has none.""" + year, month = divmod(born.year * 12 + born.month - 1 + months, 12) + next_month = date(year + (month + 1) // 12, (month + 1) % 12 + 1, 1) + last_day = (next_month - timedelta(days=1)).day + return date(year, month + 1, min(born.day, last_day)) + + +def expected_share(born, year): + reached = attains(born, MINIMUM_AGE_MONTHS[year]) + starts = (date(year, 4, 1), date(year, 9, 1), date(year + 1, 1, 1)) + return sum(reached < start for start in starts) / len(starts) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +@pytest.mark.parametrize("year", sorted(MINIMUM_AGE_MONTHS)) +def test_term_share_follows_the_date_of_birth(year): + first = date(year - 5, 10, 7) + births = [ + first + timedelta(days=d) for d in range((date(year, 10, 6) - first).days + 1) + ] + people = { + f"child_{born:%Y%m%d}": { + "age": {year: age_on_6_october(born, year)}, + "date_of_birth": {year: int(f"{born:%Y%m%d}")}, + } + for born in births + } + sim = Simulation(situation={"people": people}) + shares = sim.calculate("extended_childcare_entitlement_term_share", year) + mismatches = [ + (born, float(share), expected_share(born, year)) + for born, share in zip(births, shares) + if abs(share - expected_share(born, year)) > 1e-6 + ] + assert not mismatches, mismatches[:10] + # Every share from none to all three terms occurs. + assert {round(3 * float(share)) for share in shares} == {0, 1, 2, 3} diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py index 3c0a26aa6..9f436887e 100644 --- a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py @@ -17,7 +17,11 @@ class extended_childcare_entitlement_per_child(Variable): "becoming eligible never removes the universal or targeted hours. A " "participating child has its first funded hours allocated to the " "applicable universal or targeted programme even when standalone " - "take-up of that programme is false; only the remaining hours count here." + "take-up of that programme is false; only the remaining hours count " + "here. The comparison is term by term: a child old enough for only " + "some of the year's terms (extended_childcare_entitlement_term_share) " + "has the working parent total for those terms, set against the " + "universal or targeted hours in the same terms." ) definition_period = YEAR unit = GBP @@ -44,9 +48,18 @@ def formula(person, period, parameters): # part of the total above the universal and targeted hours. Their # award formulas allocate those first funded hours even when their # standalone take-up flags are false (2014 regs 3-4; guidance A1.11). - universal_or_targeted = person( - "universal_childcare_entitlement", period - ) + person("targeted_childcare_entitlement", period) + # The total covers the terms the child is old enough for. Universal + # or targeted hours claimed on their own run all year, so only those + # terms' share of them counts; hours allocated from working parent + # participation fall in those terms already. + term_share = person("extended_childcare_entitlement_term_share", period) + universal = person("universal_childcare_entitlement", period) + targeted = person("targeted_childcare_entitlement", period) + claims_universal = person.benunit("would_claim_universal_childcare", period) + claims_targeted = person.benunit("would_claim_targeted_childcare", period) + universal_or_targeted = where( + claims_universal, term_share * universal, universal + ) + where(claims_targeted, term_share * targeted, targeted) # Rounded to the penny: the universal and targeted values are stored # in single precision, so a total equal to them would otherwise leave # a fraction of a penny. diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_qualifying_child.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_qualifying_child.py index 30eef25cc..486654855 100644 --- a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_qualifying_child.py +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_qualifying_child.py @@ -7,17 +7,20 @@ class extended_childcare_entitlement_qualifying_child(Variable): label = "qualifying child of working parents for the extended childcare entitlement" documentation = ( "A young child for whom working parents can take up the extended " - "childcare entitlement: under compulsory school age and in England " - "(Childcare Act 2016 s.1(2)(a)-(b)), and at least the minimum age and " - "not looked after by a local authority (SI 2022/1134 reg 13(2); " - "SI 2016/1257 reg 3(1) before 1 December 2022). A child in a " - "reception class does not count: its school place meets the " - "entitlement (early_years_child_in_school)." + "childcare entitlement in at least one of the year's terms: under " + "compulsory school age and in England (Childcare Act 2016 " + "s.1(2)(a)-(b)), not looked after by a local authority, and old " + "enough by the start of a term (SI 2022/1134 regs 13(2) and 27; SI " + "2016/1257 regs 3(1) and 35 before 1 December 2022; see " + "extended_childcare_entitlement_term_share). A child in a reception " + "class does not count: its school place meets the entitlement " + "(early_years_child_in_school)." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/ukpga/2016/5/section/1", "https://www.legislation.gov.uk/uksi/2022/1134/regulation/13", + "https://www.legislation.gov.uk/uksi/2022/1134/regulation/27", "https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026", ) @@ -40,8 +43,10 @@ def formula(person, period, parameters): country = person.household("country", period) in_england = country == country.possible_values.ENGLAND + # Old enough for at least one term: a child reaching the minimum age + # after the middle of the year can qualify from the spring term. meets_minimum_age = ( - age >= p.extended_childcare_entitlement.young_child_minimum_age + person("extended_childcare_entitlement_term_share", period) > 0 ) not_looked_after = ~person("is_looked_after_by_local_authority", period) diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_term_share.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_term_share.py new file mode 100644 index 000000000..b2c52f09c --- /dev/null +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_term_share.py @@ -0,0 +1,51 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import add_months_to_yyyymmdd, birth_day + +# The model year runs from April, so its three terms begin on 1 April and +# 1 September of the year and 1 January of the next (month, day, years on). +TERM_STARTS = ((4, 1, 0), (9, 1, 0), (1, 1, 1)) + + +class extended_childcare_entitlement_term_share(Variable): + value_type = float + entity = Person + label = ( + "share of the year's terms after a child reaches the working parent minimum age" + ) + documentation = ( + "The share of the year's three terms, beginning on 1 April and 1 " + "September of the year and 1 January of the next, that begin after " + "the day the child attains the minimum age for the working parent " + "entitlement, each term a third of the year. A child is eligible " + "from the first 1 January, 1 April or 1 September after the day its " + "declaration has effect (SI 2022/1134 reg 27(1)(a)), which for a " + "declaration made in advance is the day the child attains the " + "minimum age (regs 13(2)(a), 20(a) and 25(1)); before December 2022 " + "SI 2016/1257 reg 35(3) set the same dates from the third birthday. " + "The day comes from date_of_birth, or from age and " + "months_since_last_birthday, which in simulations built from data " + "spread each single year of age over the year. So some children " + "aged 0 in the middle of the year qualify from the autumn or spring " + "term, and some aged 1 only from the autumn term." + ) + definition_period = YEAR + unit = "/1" + reference = ( + "https://www.legislation.gov.uk/uksi/2022/1134/regulation/13", + "https://www.legislation.gov.uk/uksi/2022/1134/regulation/20", + "https://www.legislation.gov.uk/uksi/2022/1134/regulation/25", + "https://www.legislation.gov.uk/uksi/2022/1134/regulation/27", + "https://www.legislation.gov.uk/uksi/2016/1257/regulation/35/made", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.dfe.extended_childcare_entitlement + attains_minimum_age = add_months_to_yyyymmdd( + birth_day(person, period), 12 * p.young_child_minimum_age + ) + year = period.start.year + terms = sum( + attains_minimum_age < (year + years_on) * 10_000 + month * 100 + day + for month, day, years_on in TERM_STARTS + ) + return terms / len(TERM_STARTS) diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_total_per_child.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_total_per_child.py index 041121376..3974b6d7d 100644 --- a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_total_per_child.py +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_total_per_child.py @@ -9,13 +9,16 @@ class extended_childcare_entitlement_total_per_child(Variable): "The annual value of this participating child's total funded working " "parent hours, before allocating the first hours to any applicable " "universal or targeted programme. Limited by the statutory age " - "schedule, the child's usage and the family's usage cap." + "schedule, the child's usage and the family's usage cap, over the " + "share of the year's terms the child is old enough for " + "(extended_childcare_entitlement_term_share)." ) definition_period = YEAR unit = GBP defined_for = "extended_childcare_entitlement_eligible" reference = ( "https://www.legislation.gov.uk/ukpga/2016/5/section/1", + "https://www.legislation.gov.uk/uksi/2022/1134/regulation/27", "https://www.legislation.gov.uk/uksi/2022/1134/regulation/44", "https://www.legislation.gov.uk/uksi/2023/1330/regulation/2/made", ) @@ -26,10 +29,19 @@ def formula(person, period, parameters): qualifying_child = person( "extended_childcare_entitlement_qualifying_child", period ) + # A child below the minimum age in the middle of the year gets the + # hours for that age in the terms after it reaches it. + hours_age = max_(age, p.extended_childcare_entitlement.young_child_minimum_age) weekly_hours = ( - p.extended_childcare_entitlement.hours.calc(age) * qualifying_child + p.extended_childcare_entitlement.hours.calc(hours_age) * qualifying_child ) child_usage = person("max_free_entitlement_hours_used", period) family_usage = person.benunit("maximum_extended_childcare_hours_usage", period) hours_used = min_(min_(weekly_hours, child_usage), family_usage) - return hours_used * p.weeks_per_year * p.childcare_funding_rate.calc(age) + term_share = person("extended_childcare_entitlement_term_share", period) + return ( + hours_used + * p.weeks_per_year + * term_share + * p.childcare_funding_rate.calc(age) + ) diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/is_child_receiving_extended_childcare.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/is_child_receiving_extended_childcare.py index d60be7afc..351fb7a2a 100644 --- a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/is_child_receiving_extended_childcare.py +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/is_child_receiving_extended_childcare.py @@ -26,11 +26,13 @@ def formula(person, period, parameters): calculated_family_amount = person.benunit.sum(child_amount) # A supplied family amount that no child's calculated hours account - # for: attribute it to every qualifying child with hours at its age. + # for: attribute it to every qualifying child with hours at its age, + # or at the minimum age for one who reaches it later in the year. p = parameters(period).gov.dfe.extended_childcare_entitlement + hours_age = max_(person("age", period), p.young_child_minimum_age) qualifying_child = person( "extended_childcare_entitlement_qualifying_child", period - ) & (p.hours.calc(person("age", period)) > 0) + ) & (p.hours.calc(hours_age) > 0) supplied_only = calculated_family_amount <= 0 return (family_amount > 0) & ( diff --git a/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement.py b/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement.py index 3491300b1..a588aed06 100644 --- a/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement.py +++ b/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/targeted_childcare_entitlement.py @@ -10,8 +10,9 @@ class targeted_childcare_entitlement(Variable): defined_for = "targeted_childcare_entitlement_eligible" documentation = ( "Targeted hours claimed on their own, or allocated first from a " - "participating child's working parent hours when standalone take-up " - "is false, as required by DfE guidance A1.11." + "participating child's working parent hours, in the terms it gets " + "them, when standalone take-up is false, as required by DfE " + "guidance A1.11." ) reference = ( "https://www.legislation.gov.uk/uksi/2014/2147/regulation/3", @@ -36,7 +37,10 @@ def formula(person, period, parameters): ) # The first funded hours are targeted whenever legally eligible. # False standalone take-up funds only the hours actually supplied by - # working parent participation, preserving the combined award. + # working parent participation, in the terms the child gets them, + # preserving the combined award. + term_share = person("extended_childcare_entitlement_term_share", period) + participating_value = min_(term_share * base_value, working_parent_total) return min_( - base_value, where(standalone_claim, base_value, working_parent_total) + base_value, where(standalone_claim, base_value, participating_value) ) diff --git a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.py b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.py index 5a95552a1..a52259f20 100644 --- a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.py +++ b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.py @@ -10,8 +10,9 @@ class universal_childcare_entitlement(Variable): unit = "currency-GBP" documentation = ( "Universal hours claimed on their own, or allocated first from a " - "participating child's working parent hours when standalone take-up " - "is false. Legal eligibility determines this allocation." + "participating child's working parent hours, in the terms it gets " + "them, when standalone take-up is false. Legal eligibility " + "determines this allocation." ) reference = ( "https://www.legislation.gov.uk/ukpga/2016/5/section/1", @@ -33,7 +34,10 @@ def formula(person, period, parameters): "extended_childcare_entitlement_total_per_child", period ) # s.1(6) counts provision available, not standalone take-up. Allocate - # the first funded hours to universal without creating unpaid hours. + # the first funded hours to universal without creating unpaid hours, + # in the terms the child gets working parent hours. + term_share = person("extended_childcare_entitlement_term_share", period) + participating_value = min_(term_share * base_value, working_parent_total) return min_( - base_value, where(standalone_claim, base_value, working_parent_total) + base_value, where(standalone_claim, base_value, participating_value) ) From a464aac656954380c992b1a1ed04dca6cde65122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:35:55 +0100 Subject: [PATCH 3/3] Date the start of school from the date of birth, not the survey interview current_education is enrolment at the survey interview, so as a reception flag it caught about half the children who start reception in September and applied to the whole year. A child is entitled to a reception place in the September following its fourth birthday (School Admissions Code 2021 para 2.17), and the place then meets the universal and working parent duties, so such a child keeps the summer term only. early_years_term_share_before_school replaces early_years_child_in_school: the share of the year's terms that begin before the child starts school, from the date of birth. - England and Wales: the September following the fourth birthday. - Scotland: primary 1 in August for children who turn five from March to the next February (Education (Scotland) Act 1980 s.32). - Northern Ireland: September for children who are four by 1 July (Education and Libraries (Northern Ireland) Order 1986 art 46(2)). Universal hours and the working parent total are scaled by it, and a child stays eligible while any term comes before school. early_years_school_start_deferred keeps the year for a deferred start or a council-funded independent reception place. The term start dates move to utils.dates.term_starts, shared with the minimum-age term share. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2567.fixed.md | 2 +- .../dfe/extended-childcare-entitlement.ipynb | 2 +- .../dfe/universal-childcare-entitlement.ipynb | 2 +- ...tended_childcare_entitlement_eligible.yaml | 16 +-- ...ended_childcare_entitlement_per_child.yaml | 2 + .../universal_childcare_entitlement.yaml | 49 ++++++-- ...versal_childcare_entitlement_eligible.yaml | 27 ++-- .../funded_early_learning_childcare.yaml | 15 ++- .../early_years_child_in_school.yaml | 48 ------- .../early_years_term_share_before_school.yaml | 114 +++++++++++++++++ ..._childcare_entitlement_floor_properties.py | 102 +++++++++------ .../tests/test_early_years_school_start.py | 118 ++++++++++++++++++ policyengine_uk/utils/dates.py | 8 ++ ...xtended_childcare_entitlement_per_child.py | 4 +- ..._childcare_entitlement_qualifying_child.py | 12 +- ...tended_childcare_entitlement_term_share.py | 14 +-- ...d_childcare_entitlement_total_per_child.py | 7 +- .../universal_childcare_entitlement.py | 9 +- ...niversal_childcare_entitlement_eligible.py | 16 ++- ...unded_early_learning_childcare_eligible.py | 13 +- .../early_years_child_in_school.py | 43 ------- .../early_years_school_start_deferred.py | 25 ++++ .../early_years_term_share_before_school.py | 72 +++++++++++ 23 files changed, 517 insertions(+), 203 deletions(-) delete mode 100644 policyengine_uk/tests/policy/baseline/household/demographic/early_years_child_in_school.yaml create mode 100644 policyengine_uk/tests/policy/baseline/household/demographic/early_years_term_share_before_school.yaml create mode 100644 policyengine_uk/tests/test_early_years_school_start.py delete mode 100644 policyengine_uk/variables/household/demographic/early_years_child_in_school.py create mode 100644 policyengine_uk/variables/household/demographic/early_years_school_start_deferred.py create mode 100644 policyengine_uk/variables/household/demographic/early_years_term_share_before_school.py diff --git a/changelog.d/2567.fixed.md b/changelog.d/2567.fixed.md index 595c5f90c..93723cc6d 100644 --- a/changelog.d/2567.fixed.md +++ b/changelog.d/2567.fixed.md @@ -1 +1 @@ -- Stop the universal and working parent free hours in England, and Scotland's funded early learning and childcare, once a child has started school. A council meets both English duties for a child in a state-funded or council-funded reception class through the school place (DfE statutory guidance, footnotes 7 and 17, paras A4.8 and C.6), and a child in primary 1 is no longer an eligible pre-school child in Scotland (Children and Young People (Scotland) Act 2014 s.47(2)(b)). The new `early_years_child_in_school` is true when `current_education` is primary or a later stage, so datasets' 4-year-olds in reception no longer get funded hours; without a `current_education` input nothing changes. Set it to false for a fee-paying independent school's reception child, who can still take funded hours. +- Stop the universal and working parent free hours in England, and Scotland's funded early learning and childcare, from the term a child starts school. A council meets both English duties for a child in a state-funded or council-funded reception class through the school place (DfE statutory guidance, footnotes 7 and 17, paras A4.8 and C.6), and a child at primary school is not an eligible pre-school child in Scotland (Children and Young People (Scotland) Act 2014 s.47(2)(b)). The new `early_years_term_share_before_school` gives the share of the year's terms before the child starts school, from the date of birth. In England a child starts reception in the September following its fourth birthday (School Admissions Code 2021 para 2.17), so most 4-year-olds keep only the summer term; Scotland's primary 1 starts in August for children who turn five from March to February, and Northern Ireland's in September for children who are four by 1 July. A household simulation that gives only a whole age of 4 takes the child to be born on 6 April, so it now gets a third of the year's universal and working parent hours. Set the new `early_years_school_start_deferred` for a child whose start is deferred, or who takes funded hours in a council-funded independent reception class, to keep the year. diff --git a/docs/book/programs/gov/dfe/extended-childcare-entitlement.ipynb b/docs/book/programs/gov/dfe/extended-childcare-entitlement.ipynb index 5069296ff..8dbaaf185 100644 --- a/docs/book/programs/gov/dfe/extended-childcare-entitlement.ipynb +++ b/docs/book/programs/gov/dfe/extended-childcare-entitlement.ipynb @@ -23,7 +23,7 @@ "\n", "The 30 hours include any universal (3- and 4-year-old) or targeted (2-year-old) hours available to the child. [Childcare Act 2016 s.1(6)](https://www.legislation.gov.uk/ukpga/2016/5/section/1) counts childcare available under the 2006 Act towards the total; [2014 regulations 3–4](https://www.legislation.gov.uk/uksi/2014/2147/made/data.html) establish those base entitlements. DfE's [statutory guidance A1.11](https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026#section-a1-eligibility) requires the first 15 hours for a child eligible for both targeted and working-parent provision to be funded as targeted hours. Qualifying for the working-parent entitlement never removes universal or targeted eligibility.\n", "\n", - "A child in a state-funded or council-funded reception class gets no funded hours outside its school place ([statutory guidance](https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026), footnotes 7 and 17, paras A4.8 and C.6), so it gets neither universal nor working-parent hours once `current_education` is primary or a later stage (`early_years_child_in_school`).\n", + "A child in a state-funded or council-funded reception class gets no funded hours outside its school place ([statutory guidance](https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026), footnotes 7 and 17, paras A4.8 and C.6). It takes up that place in the September following its fourth birthday (C.5), so most 4-year-olds get universal and working-parent hours for the summer term only. `early_years_term_share_before_school` gives the share of the year's terms before the child starts school, from its date of birth, and `early_years_school_start_deferred` keeps the year for a deferred start.\n", "\n", "### Eligibility requirements\n", "\n", diff --git a/docs/book/programs/gov/dfe/universal-childcare-entitlement.ipynb b/docs/book/programs/gov/dfe/universal-childcare-entitlement.ipynb index 22a7b12d4..aece155ad 100644 --- a/docs/book/programs/gov/dfe/universal-childcare-entitlement.ipynb +++ b/docs/book/programs/gov/dfe/universal-childcare-entitlement.ipynb @@ -48,7 +48,7 @@ "* Age requirements:\n", " * Checks child meets minimum age requirement\n", " * Ensures child is below compulsory school age\n", - " * Excludes a child who has started school, such as in a reception class, whose place meets the entitlement ([DfE statutory guidance](https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026), footnotes 7 and 17): `early_years_child_in_school`, true when `current_education` is primary or a later stage\n", + " * Stops in the terms after the child starts school: a reception place, taken up in the September following the fourth birthday, meets the entitlement ([DfE statutory guidance](https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026), footnotes 7 and 17, C.5). `early_years_term_share_before_school` gives the share of the year's terms before that, from the date of birth, so most 4-year-olds keep only the summer term; `early_years_school_start_deferred` keeps the year for a deferred start\n", " * Referenced in Section 7 of Childcare Act 2006\n", "\n", "Example table:\n", diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.yaml index 7e6ceb549..c632d4917 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_eligible.yaml @@ -181,8 +181,9 @@ # DfE's statutory guidance (April 2026, footnotes 7 and 17, paras A4.8 and # C.6): a child in a state-funded or council-funded reception class is not # entitled to free hours outside the school place, though it is still under -# compulsory school age. -- name: Not eligible - the only child is in a reception class +# compulsory school age. A child starting reception in September (C.5) still +# qualifies for the summer term. +- name: Not eligible - the only child is at school all year period: 2026 input: households: @@ -195,7 +196,7 @@ extended_childcare_entitlement_work_condition: true child1: age: 4 - current_education: PRIMARY + early_years_term_share_before_school: 0 benunits: benunit1: members: [parent1, child1] @@ -203,7 +204,7 @@ extended_childcare_entitlement_qualifying_child: [false, false] extended_childcare_entitlement_eligible: false -- name: Eligible - a child in a reception class with a 2-year-old sibling +- name: Eligible - a child at school all year with a 2-year-old sibling period: 2026 input: households: @@ -216,7 +217,7 @@ extended_childcare_entitlement_work_condition: true child1: age: 4 - current_education: PRIMARY + early_years_term_share_before_school: 0 child2: age: 2 benunits: @@ -226,7 +227,7 @@ extended_childcare_entitlement_qualifying_child: [false, false, true] extended_childcare_entitlement_eligible: true -- name: Eligible - a 4-year-old at nursery +- name: Eligible - a 4-year-old starting reception in September period: 2026 input: households: @@ -239,11 +240,12 @@ extended_childcare_entitlement_work_condition: true child1: age: 4 - current_education: PRE_PRIMARY + date_of_birth: 20220831 benunits: benunit1: members: [parent1, child1] output: + extended_childcare_entitlement_qualifying_child: [false, true] extended_childcare_entitlement_eligible: true - name: Not eligible - no children diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.yaml index dd87f1816..e0fb42771 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.yaml @@ -191,6 +191,8 @@ people: child: age: 4 + # Four on 15 September 2026, so in reception from September 2027. + date_of_birth: 20220915 benunits: benunit: members: [child] diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.yaml index a2457482c..73fa70067 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.yaml @@ -18,6 +18,8 @@ output: universal_childcare_entitlement: 3_351.60 +# A whole age is the middle of the year of age: born on 6 April 2020, so in +# reception from September 2024 and funded for the summer term only. - name: Child aged 4 is eligible (person-level) period: 2024 input: @@ -26,7 +28,7 @@ age: 4 universal_childcare_entitlement_eligible: True output: - universal_childcare_entitlement: 3_351.60 + universal_childcare_entitlement: 1_117.20 # A third of 570 hours * £5.88 - name: Parent (ineligible) period: 2024 @@ -66,7 +68,7 @@ age: 4 universal_childcare_entitlement_eligible: True output: - universal_childcare_entitlement: 3_351.60 + universal_childcare_entitlement: 1_117.20 # The summer term, as above - name: Child aged 5 (ineligible) period: 2024 @@ -106,6 +108,8 @@ people: child: age: 4 + # Four on 15 September 2024, so in reception from September 2025. + date_of_birth: 20200915 universal_childcare_entitlement_eligible: True max_free_entitlement_hours_used: 15 output: @@ -125,8 +129,10 @@ # DfE's statutory guidance (April 2026, footnotes 7 and 17, paras A4.8 and # C.6): a child in a state-funded or council-funded reception class is "not # entitled to any additional free hours outside their school reception class -# place", under either the universal or the working parent entitlement. -- name: A 4-year-old in a reception class gets no free hours +# place", under either the universal or the working parent entitlement. A +# child takes up that place in the September following its fourth birthday +# (C.5). +- name: A 4-year-old starting reception in September gets free hours for the summer term only period: 2026 absolute_error_margin: 0.05 input: @@ -139,7 +145,7 @@ employment_income: 30_000 child: age: 4 - current_education: PRIMARY + date_of_birth: 20220831 benunits: benunit: members: [parent1, parent2, child] @@ -148,10 +154,12 @@ members: [parent1, parent2, child] country: ENGLAND output: - universal_childcare_entitlement: [0, 0, 0] - extended_childcare_entitlement_per_child: [0, 0, 0] + # Four on 31 August 2026: a third of 570 universal and 570 working parent + # hours, at £6.61. + universal_childcare_entitlement: [0, 0, 1_255.90] + extended_childcare_entitlement_per_child: [0, 0, 1_255.90] -- name: A 4-year-old at nursery keeps the universal and working parent hours +- name: A 4-year-old with a September birthday keeps the year's universal and working parent hours period: 2026 absolute_error_margin: 0.05 input: @@ -164,7 +172,8 @@ employment_income: 30_000 child: age: 4 - current_education: PRE_PRIMARY + # Four on 1 September 2026, so in reception from September 2027. + date_of_birth: 20220901 benunits: benunit: members: [parent1, parent2, child] @@ -175,4 +184,24 @@ output: # 570 universal hours and 570 working parent hours, at £6.61. universal_childcare_entitlement: [0, 0, 3_767.70] - extended_childcare_entitlement_per_child: [0, 0, 3_767.70] \ No newline at end of file + extended_childcare_entitlement_per_child: [0, 0, 3_767.70] +# The School Admissions Code lets parents defer a child's start until later in +# the school year (C.5). +- name: A 4-year-old whose reception start is deferred keeps the year's hours + period: 2026 + absolute_error_margin: 0.05 + input: + people: + child: + age: 4 + date_of_birth: 20220831 + early_years_school_start_deferred: true + benunits: + benunit: + members: [child] + households: + household: + members: [child] + country: ENGLAND + output: + universal_childcare_entitlement: 3_767.70 diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.yaml index f8b3fe95a..8e26afd49 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.yaml @@ -40,36 +40,27 @@ # DfE's statutory guidance (April 2026, footnotes 7 and 17, paras A4.8 and # C.6): a child in a state-funded or council-funded reception class is not -# entitled to free hours outside the school place. -- name: Child aged 4 in a reception class in England is not eligible +# entitled to free hours outside the school place. A child starting reception +# in September is eligible for the summer term. +- name: Child aged 4 starting reception in September is eligible for the summer term period: 2026 input: age: 4 - current_education: PRIMARY - country: ENGLAND - output: - universal_childcare_entitlement_eligible: false - -- name: Child aged 4 at nursery in England is eligible - period: 2026 - input: - age: 4 - current_education: PRE_PRIMARY + date_of_birth: 20220831 country: ENGLAND output: + early_years_term_share_before_school: 0.3333 universal_childcare_entitlement_eligible: true + absolute_error_margin: 0.0001 -# A fee-paying independent school's reception class is recorded as primary -# education, but its children can take funded hours there. -- name: Child aged 4 in a reception class not counted as in school stays eligible +- name: Child at school all year is not eligible period: 2026 input: age: 4 - current_education: PRIMARY - early_years_child_in_school: false + early_years_term_share_before_school: 0 country: ENGLAND output: - universal_childcare_entitlement_eligible: true + universal_childcare_entitlement_eligible: false # Childcare Act 2016 s.1(6)(a): the universal hours count towards the working # parent's 30 hours, so eligibility for that entitlement does not remove them. diff --git a/policyengine_uk/tests/policy/baseline/gov/scottish_government/funded_early_learning_childcare.yaml b/policyengine_uk/tests/policy/baseline/gov/scottish_government/funded_early_learning_childcare.yaml index 4da8762d7..e55f08b85 100644 --- a/policyengine_uk/tests/policy/baseline/gov/scottish_government/funded_early_learning_childcare.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/scottish_government/funded_early_learning_childcare.yaml @@ -101,14 +101,16 @@ # Children and Young People (Scotland) Act 2014 s.47(2)(b): an eligible # pre-school child "has not commenced attendance at a primary school (other -# than at a nursery class in such a school)". -- name: Scottish 4-year-old in primary 1 is not eligible +# than at a nursery class in such a school)". Primary 1 starts in August for +# children who turn five from March to the next February (Education (Scotland) +# Act 1980 s.32). +- name: Scottish child at primary school all year is not eligible period: 2026 input: people: child: age: 4 - current_education: PRIMARY + early_years_term_share_before_school: 0 benunits: benunit: members: [child] @@ -119,13 +121,15 @@ output: scottish_funded_early_learning_childcare_eligible: false -- name: Scottish 4-year-old in a nursery class is eligible +- name: Scottish 4-year-old starting primary 1 in August is eligible for the summer term period: 2026 + absolute_error_margin: 0.0001 input: people: child: age: 4 - current_education: PRE_PRIMARY + # Five on 15 January 2027, so in primary 1 from August 2026. + date_of_birth: 20220115 benunits: benunit: members: [child] @@ -134,4 +138,5 @@ members: [child] region: SCOTLAND output: + early_years_term_share_before_school: 0.3333 scottish_funded_early_learning_childcare_eligible: true diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/early_years_child_in_school.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/early_years_child_in_school.yaml deleted file mode 100644 index 25cfe5c1f..000000000 --- a/policyengine_uk/tests/policy/baseline/household/demographic/early_years_child_in_school.yaml +++ /dev/null @@ -1,48 +0,0 @@ -# A child has started school once current_education is primary or a later -# stage; a 4- or 5-year-old in full-time education can be recorded at a later -# stage than primary. -- name: A 4-year-old is in school at primary or any later stage of education - period: 2026 - input: - people: - not_in_education: - age: 4 - current_education: NOT_IN_EDUCATION - nursery: - age: 4 - current_education: PRE_PRIMARY - not_completed_primary: - age: 4 - current_education: NOT_COMPLETED_PRIMARY - reception: - age: 4 - current_education: PRIMARY - lower_secondary: - age: 4 - current_education: LOWER_SECONDARY - upper_secondary: - age: 4 - current_education: UPPER_SECONDARY - post_secondary: - age: 4 - current_education: POST_SECONDARY - tertiary: - age: 4 - current_education: TERTIARY - output: - early_years_child_in_school: [false, false, false, true, true, true, true, true] - -# The age default for current_education is pre-primary below 4 and "not -# completed primary" from 4 to 6, so without an input no 3- or 4-year-old is -# in school. -- name: Without a current_education input no 3- or 4-year-old is in school - period: 2026 - input: - people: - child_aged_3: - age: 3 - child_aged_4: - age: 4 - output: - current_education: [PRE_PRIMARY, NOT_COMPLETED_PRIMARY] - early_years_child_in_school: [false, false] diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/early_years_term_share_before_school.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/early_years_term_share_before_school.yaml new file mode 100644 index 000000000..b5d16d157 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/demographic/early_years_term_share_before_school.yaml @@ -0,0 +1,114 @@ +# The share of the year's terms (from 1 April, 1 September and 1 January) +# that begin before the child starts school, from the date of birth. +# England and Wales: reception in the September following the fourth birthday +# (School Admissions Code 2021 para 2.17; DfE statutory guidance, C.5). +- name: An English 4-year-old with a fourth birthday on 31 August starts reception that September + period: 2026 + absolute_error_margin: 0.0001 + input: + age: 4 + date_of_birth: 20220831 + country: ENGLAND + output: + # The summer term only. + early_years_term_share_before_school: 0.3333 + +- name: An English 4-year-old with a fourth birthday on 1 September starts reception a year later + period: 2026 + input: + age: 4 + date_of_birth: 20220901 + country: ENGLAND + output: + early_years_term_share_before_school: 1 + +# Without a date of birth a whole age is the middle of the year of age: born +# on 6 April. +- name: Without a date of birth a 4-year-old starts reception in September, a 3-year-old a year later and a 5-year-old has started + period: 2026 + absolute_error_margin: 0.0001 + input: + people: + aged_3: + age: 3 + aged_4: + age: 4 + aged_5: + age: 5 + households: + household: + members: [aged_3, aged_4, aged_5] + country: ENGLAND + output: + early_years_term_share_before_school: [1, 0.3333, 0] + +- name: Wales follows the same September cohort + period: 2026 + absolute_error_margin: 0.0001 + input: + age: 4 + date_of_birth: 20220831 + country: WALES + output: + early_years_term_share_before_school: 0.3333 + +# Scotland: primary 1 starts in August for children who turn five by the +# council's latest date, the end of February (Education (Scotland) Act 1980 +# s.32), so the cohort runs from 1 March to the end of February. +- name: A Scottish child born on 28 February starts primary 1 in the August after its fourth birthday + period: 2026 + absolute_error_margin: 0.0001 + input: + age: 4 + date_of_birth: 20220228 + country: SCOTLAND + output: + early_years_term_share_before_school: 0.3333 + +- name: A Scottish child born on 1 March starts primary 1 a year later + period: 2026 + input: + age: 4 + date_of_birth: 20220301 + country: SCOTLAND + output: + early_years_term_share_before_school: 1 + +# Northern Ireland: primary 1 starts in September for children who are four by +# 1 July (Education and Libraries (Northern Ireland) Order 1986 art 46(2)). +- name: A Northern Irish child who is four by 1 July starts primary 1 that September + period: 2026 + absolute_error_margin: 0.0001 + input: + people: + four_by_1_july: + age: 4 + date_of_birth: 20220701 + four_on_2_july: + age: 4 + date_of_birth: 20220702 + households: + household: + members: [four_by_1_july, four_on_2_july] + country: NORTHERN_IRELAND + output: + early_years_term_share_before_school: [0.3333, 1] + +- name: Deferring one child's start keeps its year and leaves a sibling on its date of birth + period: 2026 + absolute_error_margin: 0.0001 + input: + people: + deferred: + age: 4 + date_of_birth: 20220831 + early_years_school_start_deferred: true + sibling: + age: 4 + date_of_birth: 20211015 + households: + household: + members: [deferred, sibling] + country: ENGLAND + output: + early_years_term_share_before_school: [1, 0.3333] diff --git a/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py b/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py index f498d0ce6..d68fc7094 100644 --- a/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py +++ b/policyengine_uk/tests/test_childcare_entitlement_floor_properties.py @@ -50,9 +50,10 @@ targeted floor is at most 15 hours a week. 10. Outside England no child gets universal or working parent hours (Childcare Act 2006 s.7 and 2016 s.1(2)(b) are duties for England). -11. A child in school (current_education primary, as in a reception class) - gets no universal or working parent hours: its school place meets both - duties (guidance footnotes 7 and 17, paras A4.8 and C.6). +11. No universal or working parent hours in the terms after a child starts + school: a reception place meets both duties (guidance footnotes 7 and 17, + paras A4.8 and C.6). Reception starts in the September following the + fourth birthday (guidance C.5), unless the start is deferred. 12. Working parent hours start with the first 1 January, 1 April or 1 September after the child attains the minimum age (SI 2022/1134 reg 27(1)(a)); the year's terms begin on 1 April, 1 September and 1 January, @@ -101,28 +102,39 @@ def close(a, b): return abs(a - b) <= 0.02 + 1e-6 * abs(b) -def in_school(c): - """Invariant 11: enrolled in primary education, as in a reception class.""" - return c.get("education") == "PRIMARY" - - def months_later(day, months): """The same day of the month ``months`` calendar months later.""" index = day.year * 12 + day.month - 1 + months return date(index // 12, index % 12 + 1, day.day) +def born_on(age, year): + """A whole age is the middle of the year of age; a fractional one is + exact on 6 October. The drawn ages are whole months, apart from 4.9: in + November either way, years from every boundary these tests use.""" + months_old = 12 * age if age != int(age) else 12 * age + 6 + return months_later(date(year, 10, 6), -round(months_old)) + + +def year_terms(year): + return (date(year, 4, 1), date(year, 9, 1), date(year + 1, 1, 1)) + + def term_share(age, year, minimum_age): """Invariant 12: the share of the year's terms after the child attains the minimum age.""" - # A whole age is the middle of the year of age; a fractional one is exact - # on 6 October. The drawn ages are whole months, apart from 4.9, which is - # years past any minimum age. - months_old = 12 * age if age != int(age) else 12 * age + 6 - born = months_later(date(year, 10, 6), -round(months_old)) - attains = months_later(born, round(12 * minimum_age)) - starts = (date(year, 4, 1), date(year, 9, 1), date(year + 1, 1, 1)) - return sum(attains < start for start in starts) / len(starts) + attains = months_later(born_on(age, year), round(12 * minimum_age)) + return sum(attains < start for start in year_terms(year)) / 3 + + +def before_school(c, year): + """Invariant 11: the share of the year's terms before the child starts + reception, in the September following its fourth birthday.""" + if c.get("deferred"): + return 1 + born = born_on(c["age"], year) + starts_school = date(born.year + 4 + (born.month >= 9), 9, 1) + return sum(start < starts_school for start in year_terms(year)) / 3 child = st.fixed_dictionaries( @@ -130,8 +142,8 @@ def term_share(age, year, minimum_age): "age": st.sampled_from([0, 0.5, 0.75, 1, 2, 2.5, 3, 4, 4.9, 5, 6]), "hours_used": st.sampled_from([0, 5, 10, 14.9, 15, 15.1, 20, 30, 45]), "looked_after": st.booleans(), - # None leaves current_education to its age default. - "education": st.sampled_from([None, None, "PRE_PRIMARY", "PRIMARY"]), + # A deferred start keeps the year's hours. + "deferred": st.sampled_from([False, False, False, True]), } ) family = st.fixed_dictionaries( @@ -171,9 +183,9 @@ def simulate(families, year, eligible, cap_bump=0, hours_bump=0): }, "is_looked_after_by_local_authority": {year: c["looked_after"]}, } - if c.get("education") is not None: - situation["people"][name]["current_education"] = { - year: c["education"] + if c.get("deferred"): + situation["people"][name]["early_years_school_start_deferred"] = { + year: True } members.append(name) situation["benunits"][f"b{copy}_{i}"] = { @@ -227,9 +239,11 @@ def reference(c, f, year, eligible): weeks = p.weeks_per_year under_school_age = age < 5 in_england = f["england"] + # The terms before the child starts school: none of these hours after it. + school = before_school(c, year) universal_hours = ( min(c["hours_used"] * weeks, p.universal_childcare_entitlement.hours) - if in_england and 3 <= age < 5 and not in_school(c) + if in_england and 3 <= age < 5 and school > 0 else 0 ) targeted_hours = ( @@ -241,7 +255,7 @@ def reference(c, f, year, eligible): if f["targeted"] else 0 ) - universal_value = universal_hours * rate + universal_value = universal_hours * rate * school targeted_value = targeted_hours * rate floor = ( universal_value * f["claims_universal"] + targeted_value * f["claims_targeted"] @@ -252,7 +266,7 @@ def reference(c, f, year, eligible): in_england and under_school_age and not c["looked_after"] - and not in_school(c) + and school > 0 and share > 0 ) weekly = ( @@ -266,7 +280,9 @@ def reference(c, f, year, eligible): if qualifying else 0 ) - working_parent_total = weekly * weeks * rate if eligible else 0 + # A child starting school is years past the minimum age, so the terms it + # is old enough for include every term before it starts school. + working_parent_total = weekly * weeks * rate * school if eligible else 0 # In the terms the child is old enough for: the larger of the claimed # standalone value and the working parent total, with legal eligibility, # independent of standalone take-up, determining which programme funds @@ -461,7 +477,8 @@ def test_working_parent_hours_schedule_at_rollout_boundaries( @example( 2026, [ - # A 4-year-old in a reception class and one at nursery, with a + # A 4-year-old starting reception in September (born on 6 April, so + # funded for the summer term), one whose start is deferred, and a # 2-year-old sibling. { "children": [ @@ -469,9 +486,9 @@ def test_working_parent_hours_schedule_at_rollout_boundaries( "age": age, "hours_used": 30, "looked_after": False, - "education": education, + "deferred": deferred, } - for age, education in ((4, "PRIMARY"), (4, "PRE_PRIMARY"), (2, None)) + for age, deferred in ((4, False), (4, True), (2, False)) ], "cap": 30, "claims_universal": True, @@ -531,17 +548,26 @@ def test_becoming_eligible_never_lowers_childcare_support(year, families): assert close(copy[0]["family_extended"], per_child), (year, f, copy) for c, b, a in zip(f["children"], before, after): assert funded(a) >= funded(b) - 0.02, (year, f, c, b, a) - legal_universal = f["england"] and 3 <= c["age"] < 5 and not in_school(c) + school = before_school(c, year) + legal_universal = f["england"] and 3 <= c["age"] < 5 and school > 0 assert a["universal_eligible"] == b["universal_eligible"] == legal_universal - # 11. A school place, not funded hours. - if in_school(c): - for copy in (a, b): - assert copy["universal_childcare_entitlement"] == 0, (year, f, c) - assert copy["extended_childcare_entitlement_per_child"] == 0, ( - year, - f, - c, - ) + # 11. A school place, not funded hours, once the child starts + # school: at most the terms before it. + rate = float( + system.parameters(f"{year}-01-01").gov.dfe.childcare_funding_rate.calc( + c["age"] + ) + ) + for copy in (a, b): + universal_hours = copy["universal_childcare_entitlement"] / rate + assert universal_hours <= ( + school * UNIVERSAL_OR_TARGETED_MAX_HOURS + HOURS_TOLERANCE + ), (year, f, c, copy) + assert ( + universal_hours + + copy["extended_childcare_entitlement_per_child"] / rate + <= school * WORKING_PARENT_MAX_HOURS + HOURS_TOLERANCE + ), (year, f, c, copy) # 8-10. Statutory maximum, no double floor, England only. assert_statutory_bounds(c, f, f"{year}-01-01", False, b) assert_statutory_bounds(c, f, f"{year}-01-01", True, a) diff --git a/policyengine_uk/tests/test_early_years_school_start.py b/policyengine_uk/tests/test_early_years_school_start.py new file mode 100644 index 000000000..dd5e2277d --- /dev/null +++ b/policyengine_uk/tests/test_early_years_school_start.py @@ -0,0 +1,118 @@ +"""Funded early years hours stop with the term a child starts school. + +A reception place meets England's universal and working parent duties (DfE, +Early education and childcare: statutory guidance from 1 April 2026, +footnotes 7 and 17), and a child is entitled to that place "in the September +following their fourth birthday" (its legal annex, on the School Admissions +Code). In Scotland a child who has started primary school is not an eligible +pre-school child (Children and Young People (Scotland) Act 2014 s.47(2)(b)), +and primary 1 starts in August for children who turn five by the council's +latest date, the end of February (Education (Scotland) Act 1980 s.32). In +Northern Ireland primary 1 starts in September for children who are four by +1 July (Education and Libraries (Northern Ireland) Order 1986 art 46(2)). +Sources fetched on 2026-10-22: +https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026 +https://www.legislation.gov.uk/asp/2014/8/section/47 +https://www.legislation.gov.uk/ukpga/1980/44/section/32 +https://www.legislation.gov.uk/nisi/1986/594/article/46 + +The year's terms begin on 1 April, 1 September and 1 January. For every date +of birth of a child aged 3 to 5 on 6 October, in each nation, the share of +those terms that begin before it starts school matches a reference written +with calendar dates. +""" + +from datetime import date, timedelta + +import pytest + +from policyengine_uk import Simulation + +YEAR = 2026 + + +def birthday(born, age): + # No child in these grids is born on 29 February. + return date(born.year + age, born.month, born.day) + + +def first_school_term(born, country): + """The first day of the autumn term in which the child starts school.""" + if country == "SCOTLAND": + # The August whose cohort turns five from 1 March to the end of the + # next February; it comes before the autumn term begins. + five = birthday(born, 5) + cohort_year = five.year if five >= date(five.year, 3, 1) else five.year - 1 + return date(cohort_year, 9, 1) + four = birthday(born, 4) + if country == "NORTHERN_IRELAND": + # Four by 1 July: that September; otherwise the next. + return date(four.year + (four > date(four.year, 7, 1)), 9, 1) + # England and Wales: the September following the fourth birthday. + return date(four.year + (four >= date(four.year, 9, 1)), 9, 1) + + +def expected_share(born, country): + starts = (date(YEAR, 4, 1), date(YEAR, 9, 1), date(YEAR + 1, 1, 1)) + first_term = first_school_term(born, country) + return sum(start < first_term for start in starts) / len(starts) + + +def age_on_6_october(born): + return YEAR - born.year - ((10, 6) < (born.month, born.day)) + + +@pytest.mark.parametrize( + "country", ["ENGLAND", "WALES", "SCOTLAND", "NORTHERN_IRELAND"] +) +def test_the_share_before_school_follows_each_nations_school_start(country): + first = date(YEAR - 6, 10, 7) + births = [ + first + timedelta(days=d) + for d in range((date(YEAR - 3, 10, 6) - first).days + 1) + ] + people = { + f"child_{born:%Y%m%d}": { + "age": {YEAR: age_on_6_october(born)}, + "date_of_birth": {YEAR: int(f"{born:%Y%m%d}")}, + } + for born in births + } + sim = Simulation( + situation={ + "people": people, + "households": { + "household": {"members": list(people), "country": {YEAR: country}} + }, + } + ) + shares = sim.calculate("early_years_term_share_before_school", YEAR) + mismatches = [ + (born, float(share), expected_share(born, country)) + for born, share in zip(births, shares) + if abs(share - expected_share(born, country)) > 1e-6 + ] + assert not mismatches, mismatches[:10] + # A child is at school all year, starts in September, or starts later. + assert {round(3 * float(share)) for share in shares} == {0, 1, 3} + + +def test_a_deferred_start_keeps_the_year(): + people = { + "deferred": { + "age": {YEAR: 4}, + "date_of_birth": {YEAR: 20220831}, + "early_years_school_start_deferred": {YEAR: True}, + }, + "on_time": {"age": {YEAR: 4}, "date_of_birth": {YEAR: 20220831}}, + } + sim = Simulation( + situation={ + "people": people, + "households": { + "household": {"members": list(people), "country": {YEAR: "ENGLAND"}} + }, + } + ) + shares = sim.calculate("early_years_term_share_before_school", YEAR) + assert list(shares) == pytest.approx([1, 1 / 3]) diff --git a/policyengine_uk/utils/dates.py b/policyengine_uk/utils/dates.py index 8f3c112cd..aca0af1e0 100644 --- a/policyengine_uk/utils/dates.py +++ b/policyengine_uk/utils/dates.py @@ -198,3 +198,11 @@ def birth_day(person, period) -> np.ndarray: """A person's day of birth as a YYYYMMDD number: date_of_birth where given, and otherwise the day age and months_since_last_birthday place.""" return grid_months_to_yyyymmdd(birth_instant(person, period)) + + +def term_starts(year: int) -> tuple: + """The YYYYMMDD days the year's three school terms begin, for rules + that turn on terms, such as funded early years hours. The year runs from + April, so they are 1 April and 1 September of the year and 1 January of + the next; each counts as a third of the year.""" + return (year * 10_000 + 401, year * 10_000 + 901, (year + 1) * 10_000 + 101) diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py index 9f436887e..4126fbe0c 100644 --- a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_per_child.py @@ -51,7 +51,9 @@ def formula(person, period, parameters): # The total covers the terms the child is old enough for. Universal # or targeted hours claimed on their own run all year, so only those # terms' share of them counts; hours allocated from working parent - # participation fall in those terms already. + # participation fall in those terms already. Terms after the child + # starts school are already out of both the total and the universal + # hours: a child starting school is years past the minimum age. term_share = person("extended_childcare_entitlement_term_share", period) universal = person("universal_childcare_entitlement", period) targeted = person("targeted_childcare_entitlement", period) diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_qualifying_child.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_qualifying_child.py index 486654855..9cb3fdbcb 100644 --- a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_qualifying_child.py +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_qualifying_child.py @@ -12,9 +12,9 @@ class extended_childcare_entitlement_qualifying_child(Variable): "s.1(2)(a)-(b)), not looked after by a local authority, and old " "enough by the start of a term (SI 2022/1134 regs 13(2) and 27; SI " "2016/1257 regs 3(1) and 35 before 1 December 2022; see " - "extended_childcare_entitlement_term_share). A child in a reception " - "class does not count: its school place meets the entitlement " - "(early_years_child_in_school)." + "extended_childcare_entitlement_term_share), and not yet at school " + "for all of them: a reception place meets the entitlement " + "(early_years_term_share_before_school)." ) definition_period = YEAR reference = ( @@ -55,13 +55,13 @@ def formula(person, period, parameters): # duty: DfE's statutory guidance (April 2026, footnotes 7 and 17, # paras A4.8 and C.6) gives no free hours outside it. Childcare Act # 2016 s.1(6) splits a child's hours between this entitlement and the - # universal one, so both leave it out. - in_school = person("early_years_child_in_school", period) + # universal one, so both stop when it starts school. + before_school = person("early_years_term_share_before_school", period) return ( under_compulsory_school_age & in_england & meets_minimum_age & not_looked_after - & ~in_school + & (before_school > 0) ) diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_term_share.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_term_share.py index b2c52f09c..45b6860d3 100644 --- a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_term_share.py +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_term_share.py @@ -1,9 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.dates import add_months_to_yyyymmdd, birth_day - -# The model year runs from April, so its three terms begin on 1 April and -# 1 September of the year and 1 January of the next (month, day, years on). -TERM_STARTS = ((4, 1, 0), (9, 1, 0), (1, 1, 1)) +from policyengine_uk.utils.dates import add_months_to_yyyymmdd, birth_day, term_starts class extended_childcare_entitlement_term_share(Variable): @@ -43,9 +39,5 @@ def formula(person, period, parameters): attains_minimum_age = add_months_to_yyyymmdd( birth_day(person, period), 12 * p.young_child_minimum_age ) - year = period.start.year - terms = sum( - attains_minimum_age < (year + years_on) * 10_000 + month * 100 + day - for month, day, years_on in TERM_STARTS - ) - return terms / len(TERM_STARTS) + starts = term_starts(period.start.year) + return sum(attains_minimum_age < start for start in starts) / len(starts) diff --git a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_total_per_child.py b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_total_per_child.py index 3974b6d7d..1cb9c1453 100644 --- a/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_total_per_child.py +++ b/policyengine_uk/variables/gov/dfe/extended_childcare_entitlement/extended_childcare_entitlement_total_per_child.py @@ -11,7 +11,10 @@ class extended_childcare_entitlement_total_per_child(Variable): "universal or targeted programme. Limited by the statutory age " "schedule, the child's usage and the family's usage cap, over the " "share of the year's terms the child is old enough for " - "(extended_childcare_entitlement_term_share)." + "(extended_childcare_entitlement_term_share) and not yet at school " + "(early_years_term_share_before_school). The minimum age is below " + "the school starting age, so the two shares never both fall short " + "of the whole year." ) definition_period = YEAR unit = GBP @@ -39,9 +42,11 @@ def formula(person, period, parameters): family_usage = person.benunit("maximum_extended_childcare_hours_usage", period) hours_used = min_(min_(weekly_hours, child_usage), family_usage) term_share = person("extended_childcare_entitlement_term_share", period) + before_school = person("early_years_term_share_before_school", period) return ( hours_used * p.weeks_per_year * term_share + * before_school * p.childcare_funding_rate.calc(age) ) diff --git a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.py b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.py index a52259f20..3052ceac9 100644 --- a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.py +++ b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement.py @@ -12,11 +12,13 @@ class universal_childcare_entitlement(Variable): "Universal hours claimed on their own, or allocated first from a " "participating child's working parent hours, in the terms it gets " "them, when standalone take-up is false. Legal eligibility " - "determines this allocation." + "determines this allocation. Only the terms before the child starts " + "school count (early_years_term_share_before_school)." ) reference = ( "https://www.legislation.gov.uk/ukpga/2016/5/section/1", "https://www.legislation.gov.uk/uksi/2014/2147/regulation/4", + "https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026", ) def formula(person, period, parameters): @@ -28,7 +30,10 @@ def formula(person, period, parameters): total_hours_used = max_hours_used * weeks hours_to_use = min_(total_hours_used, hours) - base_value = hours_to_use * p.childcare_funding_rate.calc(age) + # A reception place meets the duty from the term the child starts + # school (guidance footnote 7). + before_school = person("early_years_term_share_before_school", period) + base_value = hours_to_use * before_school * p.childcare_funding_rate.calc(age) standalone_claim = person.benunit("would_claim_universal_childcare", period) working_parent_total = person( "extended_childcare_entitlement_total_per_child", period diff --git a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.py b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.py index 5ec9b2a64..f4631369f 100644 --- a/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.py +++ b/policyengine_uk/variables/gov/dfe/universal_childcare_entitlement/universal_childcare_entitlement_eligible.py @@ -7,9 +7,10 @@ class universal_childcare_entitlement_eligible(Variable): label = "eligible for universal childcare entitlement" definition_period = YEAR documentation = ( - "Legal eligibility, independent of standalone take-up. A child in a " - "reception class gets no universal hours: its school place meets the " - "entitlement (early_years_child_in_school)." + "Legal eligibility, independent of standalone take-up, in at least " + "one of the year's terms. A child in a reception class gets no " + "universal hours: its school place meets the entitlement " + "(early_years_term_share_before_school)." ) reference = ( "https://www.legislation.gov.uk/ukpga/2006/21/section/7", @@ -43,6 +44,9 @@ def formula(person, period, parameters): # for a child in a state-funded or council-funded reception class # through the school place: DfE's statutory guidance (April 2026, # footnotes 7 and 17, paras A4.8 and C.6) gives such a child no free - # hours outside it. - in_school = person("early_years_child_in_school", period) - return in_england & meets_age_condition & not_compulsory_age & ~in_school + # hours outside it. A child starting reception in September keeps + # the summer term. + before_school = person("early_years_term_share_before_school", period) + return ( + in_england & meets_age_condition & not_compulsory_age & (before_school > 0) + ) diff --git a/policyengine_uk/variables/gov/scottish_government/funded_early_learning_childcare/scottish_funded_early_learning_childcare_eligible.py b/policyengine_uk/variables/gov/scottish_government/funded_early_learning_childcare/scottish_funded_early_learning_childcare_eligible.py index bb2a1d8f9..273695a49 100644 --- a/policyengine_uk/variables/gov/scottish_government/funded_early_learning_childcare/scottish_funded_early_learning_childcare_eligible.py +++ b/policyengine_uk/variables/gov/scottish_government/funded_early_learning_childcare/scottish_funded_early_learning_childcare_eligible.py @@ -11,13 +11,16 @@ class scottish_funded_early_learning_childcare_eligible(Variable): "not yet model the eligible-two-year-old strand (vulnerable / low-income " "children aged 2 also qualify under Part 2 of the 2014 Order). " "Three-year-olds qualify only once they have reached the statutory " - "start date. A child who has started primary 1 no longer qualifies " - "(early_years_child_in_school)." + "start date. Eligible for at least one of the year's terms: a child " + "stops qualifying when it starts primary 1, so one starting in " + "August qualifies for the summer term only " + "(early_years_term_share_before_school)." ) definition_period = YEAR reference = [ "https://www.legislation.gov.uk/asp/2014/8/section/47", "https://www.legislation.gov.uk/asp/2014/8/section/48", + "https://www.legislation.gov.uk/ukpga/1980/44/section/32", "https://www.gov.scot/publications/early-learning-childcare-statutory-guidance-july-2021/pages/8/", ] @@ -40,8 +43,10 @@ def formula(person, period, parameters): # Children and Young People (Scotland) Act 2014 s.47(2)(b): an # eligible pre-school child "has not commenced attendance at a # primary school (other than at a nursery class in such a school)". - in_school = person("early_years_child_in_school", period) + before_school = person("early_years_term_share_before_school", period) return ( - in_scotland & (eligible_three_year_old | eligible_older_child) & ~in_school + in_scotland + & (eligible_three_year_old | eligible_older_child) + & (before_school > 0) ) diff --git a/policyengine_uk/variables/household/demographic/early_years_child_in_school.py b/policyengine_uk/variables/household/demographic/early_years_child_in_school.py deleted file mode 100644 index de8a1ebab..000000000 --- a/policyengine_uk/variables/household/demographic/early_years_child_in_school.py +++ /dev/null @@ -1,43 +0,0 @@ -from policyengine_uk.model_api import * -from policyengine_uk.variables.household.demographic.highest_education import ( - EducationType, -) - -# Primary education or a later stage. A 4- or 5-year-old in full-time -# education can be recorded at a later stage than primary, so all of them -# count. -SCHOOL_STAGES = ( - EducationType.PRIMARY, - EducationType.LOWER_SECONDARY, - EducationType.UPPER_SECONDARY, - EducationType.POST_SECONDARY, - EducationType.TERTIARY, -) - - -class early_years_child_in_school(Variable): - value_type = bool - entity = Person - label = "early years child in school" - documentation = ( - "Whether a child of early years age has started school: " - "current_education is primary or a later stage. In England that is " - "a reception class, whose place meets the universal and working " - "parent entitlements, so no funded hours are given outside it. In " - "Scotland it is primary 1, after which a child is no longer an " - "eligible pre-school child for funded early learning and childcare. " - "Without a current_education input a 4-year-old is not in school, " - "since the age default for 4- to 6-year-olds is " - "NOT_COMPLETED_PRIMARY. Primary includes fee-paying independent " - "schools, whose reception children can still take funded hours: set " - "this to false for them." - ) - definition_period = YEAR - reference = ( - "https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026", - "https://www.legislation.gov.uk/asp/2014/8/section/47", - ) - - def formula(person, period, parameters): - education = person("current_education", period) - return np.logical_or.reduce([education == stage for stage in SCHOOL_STAGES]) diff --git a/policyengine_uk/variables/household/demographic/early_years_school_start_deferred.py b/policyengine_uk/variables/household/demographic/early_years_school_start_deferred.py new file mode 100644 index 000000000..b1c517b59 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/early_years_school_start_deferred.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class early_years_school_start_deferred(Variable): + value_type = bool + entity = Person + label = "school start deferred, keeping funded early years hours" + documentation = ( + "Whether a child keeps funded early years hours for the whole year " + "although its date of birth puts its start at school in this year's " + "autumn term (early_years_term_share_before_school). That is so when " + "its start is deferred: in England until later in the school year, " + "or a year for a summer-born child admitted out of its age group; in " + "Scotland for a deferred primary 1 year, funded since August 2023 " + "(SSI 2021/67). Set it too for a child who takes its funded hours in " + "a council-funded independent school reception class, where they are " + "still delivered as early years hours." + ) + definition_period = YEAR + default_value = False + reference = ( + "https://www.gov.uk/government/publications/school-admissions-code--2", + "https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026", + "https://www.legislation.gov.uk/ssi/2021/67/made", + ) diff --git a/policyengine_uk/variables/household/demographic/early_years_term_share_before_school.py b/policyengine_uk/variables/household/demographic/early_years_term_share_before_school.py new file mode 100644 index 000000000..7fffecd45 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/early_years_term_share_before_school.py @@ -0,0 +1,72 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day, term_starts + +# The month and day each nation's school cohort is born from: a child born on +# or after it starts school a year later than one born earlier in the same +# calendar year. +# - England and Wales: the September following the fourth birthday (School +# Admissions Code 2021 para 2.17), so the cohort runs from 1 September. +# - Scotland: primary 1 starts in August for children who turn five by the +# council's latest date (Education (Scotland) Act 1980 s.32), the end of +# February, so the cohort runs from 1 March. +# - Northern Ireland: primary 1 starts in September for children who are four +# by 1 July (Education and Libraries (Northern Ireland) Order 1986 art +# 46(2)), so the cohort runs from 2 July. +COHORT_STARTS = { + "ENGLAND": 901, + "WALES": 901, + "SCOTLAND": 301, + "NORTHERN_IRELAND": 702, +} + + +class early_years_term_share_before_school(Variable): + value_type = float + entity = Person + label = "share of the year's terms before a child starts school" + documentation = ( + "The share of the year's three terms, beginning on 1 April and 1 " + "September of the year and 1 January of the next, that begin before " + "the child starts school. Funded early years hours stop then: a " + "reception place meets England's universal and working parent " + "duties, and a child at primary school is not an eligible pre-school " + "child in Scotland. The start comes from the date of birth. In " + "England and Wales a child starts reception in the September " + "following its fourth birthday. In Scotland primary 1 starts in " + "August for children who turn five from March to the next February. " + "In Northern Ireland it starts in September for children who are " + "four by 1 July. So most 4-year-olds start school during the year " + "and keep only the summer term: in England, those aged 4 on 6 " + "October whose fourth birthday was before 1 September. A child whose " + "start is deferred keeps the whole year " + "(early_years_school_start_deferred). A situation that sets this " + "share directly for some people gives the others 1." + ) + definition_period = YEAR + unit = "/1" + default_value = 1 + reference = ( + "https://www.gov.uk/government/publications/school-admissions-code--2", + "https://www.gov.uk/government/publications/early-education-and-childcare--2/early-education-and-childcare-valid-from-1-april-2026", + "https://www.legislation.gov.uk/asp/2014/8/section/47", + "https://www.legislation.gov.uk/ukpga/1980/44/section/32", + "https://www.legislation.gov.uk/nisi/1986/594/article/46", + ) + + def formula(person, period, parameters): + born = birth_day(person, period) + country = person.household("country", period) + countries = country.possible_values + cohort_start = np.select( + [country == getattr(countries, name) for name in COHORT_STARTS], + list(COHORT_STARTS.values()), + default=COHORT_STARTS["ENGLAND"], + ) + start_year = born // 10_000 + 4 + (born % 10_000 >= cohort_start) + # Scotland's August start comes between the summer and autumn terms, + # so every nation's school year starts with its autumn term. + school_start = start_year * 10_000 + 901 + starts = term_starts(period.start.year) + share = sum(start < school_start for start in starts) / len(starts) + deferred = person("early_years_school_start_deferred", period) + return where(deferred, 1, share)