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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions changelog_entry.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
- bump: minor
changes:
added:
- Two child limit repeal from April 2026 (Autumn Budget 2025) - sets UC and Tax Credits child element limit to infinity
- Salary sacrifice pension cap of £2,000 from April 2029 (Autumn Budget 2025)
fixed:
- Fix fiscal year parameter handling to use April 30 reference date for annual queries, ensuring policies that change on April 6 (UK fiscal year start) are correctly reflected in simulations.
56 changes: 56 additions & 0 deletions policyengine_uk/tax_benefit_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@
from typing import Any, Dict, List

# PolicyEngine core imports
from policyengine_core import periods
from policyengine_core.parameters.operations.propagate_parameter_metadata import (
propagate_parameter_metadata,
)
from policyengine_core.parameters.operations.uprate_parameters import (
uprate_parameters,
)
from policyengine_core.parameters.parameter_node_at_instant import (
ParameterNodeAtInstant,
)
from policyengine_core.periods import Instant, Period
from policyengine_core.taxbenefitsystems import TaxBenefitSystem
from policyengine_core.variables import Variable

Expand All @@ -32,6 +37,7 @@
)
from policyengine_uk.utils.parameters import (
backdate_parameters,
convert_instant_to_fiscal_year,
convert_to_fiscal_year_parameters,
)

Expand Down Expand Up @@ -66,6 +72,56 @@ def reset_parameter_caches(self):
parameter._at_instant_cache = {}
self.parameters._at_instant_cache = {}

def get_parameters_at_instant(
self, instant: Instant
) -> ParameterNodeAtInstant:
"""
Get parameters at an instant, with UK fiscal year adjustment.

The UK fiscal year runs April 6 to April 5. When querying for a year
(e.g., "2026" or January 1, 2026), this method converts the instant
to April 30 of that year to get the correct fiscal year value.

This ensures that queries like param("2026") return the value for
fiscal year 2026/27 (April 6, 2026 - April 5, 2027) rather than
the January 1, 2026 value.

Args:
instant: The instant to query, as string, int, Period, or Instant

Returns:
ParameterNodeAtInstant with all parameter values at that instant
"""
# Convert to Instant if needed
if isinstance(instant, Period):
instant = instant.start
elif isinstance(instant, (str, int)):
instant = periods.instant(instant)
else:
assert isinstance(
instant, Instant
), f"Expected Instant, got: {instant}"

# Apply UK fiscal year conversion
# If querying January 1 of a year, convert to April 30 to get
# the fiscal year value (UK tax year starts April 6)
instant_str = str(instant)
fiscal_instant_str = convert_instant_to_fiscal_year(instant_str)
fiscal_instant = periods.instant(fiscal_instant_str)

# Use fiscal instant for cache key and lookup
parameters_at_instant = self._parameters_at_instant_cache.get(
fiscal_instant
)
if parameters_at_instant is None and self.parameters is not None:
parameters_at_instant = self.parameters.get_at_instant(
str(fiscal_instant)
)
self._parameters_at_instant_cache[fiscal_instant] = (
parameters_at_instant
)
return parameters_at_instant

def reset_parameters(self) -> None:
"""Reset parameters by reloading from the parameters directory."""
self._parameters_at_instant_cache = {}
Expand Down
190 changes: 190 additions & 0 deletions policyengine_uk/tests/test_fiscal_year_parameters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""
Tests for UK fiscal year parameter handling.

The UK fiscal year runs April 6 to April 5. PolicyEngine UK uses
on-the-fly fiscal year conversion in get_parameters_at_instant() to
convert January 1 queries to April 30, getting the correct fiscal year value.

This test suite verifies that annual period queries (e.g., param("2026"))
return the correct fiscal year values, especially for policy changes
that take effect on April 6.

Key policy examples:
- Two-child limit repeal: limit=2 until April 5, 2026, then infinity from April 6, 2026
- Income tax threshold freeze extension: threshold frozen from April 6, 2028
"""

import pytest
from policyengine_uk import CountryTaxBenefitSystem
from policyengine_uk.utils.parameters import convert_instant_to_fiscal_year
from policyengine_core.periods import period


@pytest.fixture(scope="module")
def uk_system():
"""Create a UK tax-benefit system for testing."""
return CountryTaxBenefitSystem()


class TestInstantConversion:
"""Tests for the convert_instant_to_fiscal_year function."""

def test_year_only_converts_to_april_30(self):
"""Year-only queries should convert to April 30."""
assert convert_instant_to_fiscal_year("2026") == "2026-04-30"
assert convert_instant_to_fiscal_year("2025") == "2025-04-30"
assert convert_instant_to_fiscal_year("2030") == "2030-04-30"

def test_january_1_converts_to_april_30(self):
"""January 1 queries should convert to April 30."""
assert convert_instant_to_fiscal_year("2026-01-01") == "2026-04-30"
assert convert_instant_to_fiscal_year("2025-01-01") == "2025-04-30"

def test_other_dates_unchanged(self):
"""Specific dates (not Jan 1) should not be modified."""
assert convert_instant_to_fiscal_year("2026-04-30") == "2026-04-30"
assert convert_instant_to_fiscal_year("2026-06-15") == "2026-06-15"
assert convert_instant_to_fiscal_year("2026-04-06") == "2026-04-06"
assert convert_instant_to_fiscal_year("2026-12-31") == "2026-12-31"


class TestFiscalYearConversion:
"""Tests for the fiscal year parameter conversion."""

def test_period_start_is_january_1(self):
"""Verify that period('2026') starts on January 1."""
p = period("2026")
assert p.start.year == 2026
assert p.start.month == 1
assert p.start.day == 1

def test_get_parameters_at_instant_uses_fiscal_year(self, uk_system):
"""
Test that get_parameters_at_instant converts Jan 1 to April 30.

When querying parameters for "2026", the system should use
April 30, 2026 as the reference date, getting the fiscal year value.
"""
# Get parameters at "2026" (should use April 30 internally)
params_2026 = uk_system.get_parameters_at_instant("2026")

# The two-child limit should be infinity (repealed April 6, 2026)
child_limit = (
params_2026.gov.dwp.universal_credit.elements.child.limit.child_count
)
assert child_limit == float("inf"), (
f"Expected infinity for 2026 fiscal year, got {child_limit}. "
"The get_parameters_at_instant may not be converting to April 30."
)


class TestTwoChildLimitRepeal:
"""Tests specifically for the two-child limit repeal on April 6, 2026."""

def test_two_child_limit_fiscal_year_2025(self, uk_system):
"""Test that the two-child limit is 2 for fiscal year 2025/26."""
# Get parameters at fiscal year 2025 (uses April 30, 2025)
params = uk_system.get_parameters_at_instant("2025")
child_limit = (
params.gov.dwp.universal_credit.elements.child.limit.child_count
)
assert child_limit == 2

def test_two_child_limit_fiscal_year_2026(self, uk_system):
"""Test that the two-child limit is infinity for fiscal year 2026/27."""
# Get parameters at fiscal year 2026 (uses April 30, 2026)
params = uk_system.get_parameters_at_instant("2026")
child_limit = (
params.gov.dwp.universal_credit.elements.child.limit.child_count
)
assert child_limit == float("inf")

def test_annual_query_reflects_fiscal_year(self, uk_system):
"""
Test that annual queries return fiscal year values.

For year 2026, the fiscal year is April 6, 2026 to April 5, 2027.
The two-child limit is repealed on April 6, 2026, so:
- 2025 should be 2 (fiscal year 2025/26: April 6, 2025 - April 5, 2026)
- 2026 should be infinity (fiscal year 2026/27: April 6, 2026 - April 5, 2027)
"""
params_2025 = uk_system.get_parameters_at_instant("2025")
params_2026 = uk_system.get_parameters_at_instant("2026")
params_2027 = uk_system.get_parameters_at_instant("2027")

limit_2025 = (
params_2025.gov.dwp.universal_credit.elements.child.limit.child_count
)
limit_2026 = (
params_2026.gov.dwp.universal_credit.elements.child.limit.child_count
)
limit_2027 = (
params_2027.gov.dwp.universal_credit.elements.child.limit.child_count
)

assert limit_2025 == 2
assert limit_2026 == float("inf")
assert limit_2027 == float("inf")


class TestThresholdFreezeExtension:
"""Tests for income tax threshold freeze extension (April 6, 2028)."""

def test_pa_threshold_freeze_dates(self, uk_system):
"""Test that PA threshold freeze is correctly parameterized."""
# This tests the threshold_freeze_end parameter
param_path = (
"gov.hmrc.income_tax.allowances."
"personal_allowance.threshold_freeze_end"
)
try:
param = uk_system.parameters.get_child(param_path)
# Verify the freeze end date parameter exists
assert param is not None
except Exception:
# Parameter may have different structure
pytest.skip(
"Personal allowance threshold freeze end parameter not found"
)


class TestFiscalYearConversionCoverage:
"""Tests to verify fiscal year conversion covers all years."""

@pytest.mark.parametrize("year", [2025, 2026, 2027, 2028, 2029, 2030])
def test_year_in_conversion_range(self, uk_system, year):
"""
Test that each year from 2025-2030 can be queried.

This test verifies that get_parameters_at_instant works for
all years that might be used in simulations.
"""
# Query should work without error
params = uk_system.get_parameters_at_instant(str(year))
assert params is not None
# Verify we can access a simple parameter (not a scale)
pa = params.gov.hmrc.income_tax.allowances.personal_allowance.amount
assert pa is not None
assert pa > 0 # Personal allowance should be positive


class TestSpecificDateQueries:
"""Tests to ensure specific date queries are not modified."""

def test_april_5_query_unchanged(self, uk_system):
"""Querying April 5, 2026 should return pre-repeal value."""
# Direct parameter query at April 5
param = uk_system.parameters.get_child(
"gov.dwp.universal_credit.elements.child.limit.child_count"
)
# April 5, 2026 is the last day before repeal
assert param("2026-04-05") == 2

def test_april_6_query_unchanged(self, uk_system):
"""Querying April 6, 2026 should return post-repeal value."""
# Direct parameter query at April 6
param = uk_system.parameters.get_child(
"gov.dwp.universal_credit.elements.child.limit.child_count"
)
# April 6, 2026 is the first day of repeal
assert param("2026-04-06") == float("inf")
51 changes: 42 additions & 9 deletions policyengine_uk/utils/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,47 @@ def backdate_parameters(
return root


def convert_instant_to_fiscal_year(instant_str: str) -> str:
"""
Convert an instant to use UK fiscal year reference date.

The UK fiscal year runs April 6 to April 5. When querying for January 1
of a year, we should use April 30 of that year to get the fiscal year value.

Args:
instant_str: Date string in format "YYYY-MM-DD" or "YYYY"

Returns:
Date string adjusted for UK fiscal year (April 30 if year-only input)

Example:
"2026" -> "2026-04-30" (gets fiscal year 2026/27 value)
"2026-01-01" -> "2026-04-30" (same adjustment)
"2026-04-30" -> "2026-04-30" (no change, already mid-fiscal-year)
"2026-06-15" -> "2026-06-15" (no change, specific date requested)
"""
# Only convert if it's a year-only or January 1 query
# This preserves behavior for specific date queries
if len(instant_str) == 4: # Year only: "2026"
return f"{instant_str}-04-30"
elif instant_str.endswith("-01-01"): # January 1: "2026-01-01"
year = instant_str[:4]
return f"{year}-04-30"
else:
# Specific date requested - don't modify
return instant_str


def convert_to_fiscal_year_parameters(parameters):
YEARS = list(range(2015, 2026))
for param in parameters.get_descendants():
if isinstance(param, Parameter):
for year in YEARS:
value_mid_year = param(f"{year}-04-30")
param.update(
period=f"{year}",
value=value_mid_year,
)
"""
DEPRECATED: This function pre-computes fiscal year values.

For on-the-fly fiscal year handling, use convert_instant_to_fiscal_year()
in get_parameters_at_instant() instead.

This function is kept for backward compatibility but will be removed
in a future version.
"""
# No longer pre-compute - fiscal year conversion is now done on-the-fly
# in CountryTaxBenefitSystem.get_parameters_at_instant()
return parameters
Loading