Skip to content
1 change: 1 addition & 0 deletions changelog.d/2304.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Every reform route now writes dated changes onto the statutory parameter timeline and reprocesses it as current law is processed. A dict reform dated 6 April now sets that fiscal year, and uprating, threshold indexation and the aligned NICs thresholds follow reformed values. A bounded change on an uprated or indexed parameter now resumes prior law's projected path, not a stale stated value. Reforms applied after the data load now reach the Universal Credit rebalancing inputs and the parameter-gated structural reforms. `parameters.baseline` stays current law under `Scenario(parameter_changes=...)`.
6 changes: 4 additions & 2 deletions docs/book/policy/uc-rebalancing.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,13 @@ sim = Simulation(scenario=scenario)
```python
from policyengine_uk import Simulation, Scenario

# Set the new claimant health element to £250 per month
# Set the new claimant health element to £250 per month from April 2026.
# A "start.stop" key sets the value over those dates. A single date such as
# "2026-01-01" would change that one day only and leave every year unchanged.
scenario = Scenario(
parameter_changes={
"gov.dwp.universal_credit.rebalancing.new_claimant_health_element": {
"2026-01-01": 250.00
"2026-04-06.2100-12-31": 250.00
}
}
)
Expand Down
46 changes: 45 additions & 1 deletion docs/book/usage/scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ A scenario that gradually increases the personal allowance over four years - thi
# This kind of phasing helps manage fiscal costs and economic adjustment
phased_scenario = Scenario(
parameter_changes={
"gov.hmrc.income_tax.personal_allowance": {
"gov.hmrc.income_tax.allowances.personal_allowance.amount": {
"2025": 13_000,
"2026": 14_000,
"2027": 15_000,
Expand All @@ -354,6 +354,50 @@ for year in [2025, 2026, 2027, 2028]:
print(f"{year}: Personal allowance £{allowance:.0f}, annual tax £{tax:.2f}")
```

### Dated changes

Budgets date their measures: a rate from 6 April, a freeze extended to a later tax year, a change that ends. Every way of passing a reform writes its changes onto each parameter's statutory timeline, the dated values in the YAML files. That covers `Scenario(parameter_changes=...)`, `Simulation(reform=dict)`, `Scenario.from_reform` and policyengine-core's `Reform.from_dict`. The parameters are then processed as current law is:

- uprating and the income tax and NICs threshold indexation project each parameter forward;
- each parameter is annualised to UK fiscal years by its rule: its value on 30 April, or a day-weighted average over 6 April to 5 April for `fiscal_year_blend` parameters such as the CGT rates. Parameters marked `preserve_calendar_dates` keep their dates.

So a change from 6 April 2027 sets fiscal year 2027. A change to the personal allowance after 2030-31 moves the NICs thresholds aligned with it. A change to a CPI forecast moves everything uprated from it.

```python
from policyengine_uk import Simulation

rate = "gov.hmrc.income_tax.rates.uk[0].rate"
situation = {
"people": {"person": {"age": {2027: 40}, "employment_income": {2027: 30_000}}},
"benunits": {"benunit": {"members": ["person"]}},
"households": {"household": {"members": ["person"]}},
}

# A basic rate of 21% from 6 April 2027 onwards.
from_april = Simulation(
situation=situation, reform={rate: {"2027-04-06.2100-12-31": 0.21}}
)
# The same rate for 2027-28 only: 2028-29 is back to current law.
one_year = Simulation(
situation=situation, reform={rate: {"2027-04-06.2028-04-05": 0.21}}
)
```

Outside a change's dates, prior law stays in force. Where the timeline states later values, they apply on their dates. Where the parameter's future is projected by uprating or indexation, prior law's projected path resumes after a change that ends: a one-year change to the UC standard allowance returns to the uprated amounts the next year. A change "from a date onwards" is held flat to the end of 2100 and is not uprated.

Each way of passing a reform reads period keys as it always has:

| Key | `reform=` dict | `parameter_changes` | `Reform.from_dict` |
|---|---|---|---|
| `"2027-04-06.2028-04-05"` | those dates | those dates | those dates |
| `"2027"` or `2027` | fiscal year 2027 only | fiscal year 2027 only | from 1 January 2027 onwards |
| `"2027-04-06"` | that one day of the annualised value | that one day | from that day onwards |
| a value with no period | from 1 January 2023 onwards | 2000 to 2099 | 2000 to 2099 |

A single-date key in a `reform=` dict overrides the annualised value on that day only. Formulas read annual values on 1 January, so `"2027-01-01"` sets fiscal year 2027 and `"2027-04-06"` changes nothing. The model logs a warning when a key can change nothing. Use `"start.stop"` keys for dated measures.

Changes applied after the data is loaded (the default) leave the data as built, so a changed economic assumption does not re-uprate incomes. They still come before the steps that read parameters while the simulation is built: the Universal Credit rebalancing awards and the structural reforms that parameters switch on. Set `applied_before_data_load=True` on a scenario to project the data with changed economic assumptions too.

### Creating complex scenarios with simulation modifiers

```{important}
Expand Down
80 changes: 55 additions & 25 deletions policyengine_uk/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from policyengine_core.tools.hugging_face import download_huggingface_dataset
from policyengine_core.tracers import FullTracer, SimpleTracer

from policyengine_uk.utils.dated_reforms import read_parameter_changes
from policyengine_uk.utils.parameters import (
check_parameter_not_removed,
uk_fiscal_year_period,
Expand Down Expand Up @@ -48,6 +49,14 @@

from .tax_benefit_system import CountryTaxBenefitSystem, DEFAULT_DATASET_ENV_VAR


def _canonical_parameter_paths(changes: dict) -> dict:
"""Changes keyed by canonical parameter paths (labour supply aliases)."""
return {
canonicalize_lsr_parameter_path(path): value for path, value in changes.items()
}


from microdf import MicroDataFrame

# Cache for fully-loaded, uprated, enum-pre-encoded multi-year datasets keyed
Expand Down Expand Up @@ -166,12 +175,13 @@ def __init__(

self.branches: Dict[str, Simulation] = {}

# A scenario's parameter changes rebuild the parameters from the
# statutory tree, so they come before its modifier, whose edits a
# rebuild would discard.
if scenario is not None and scenario.applied_before_data_load:
self.apply_scenario_parameters(scenario)
if scenario.simulation_modifier is not None:
scenario.simulation_modifier(self)

if scenario.parameter_changes is not None:
self.apply_parameter_changes(scenario.parameter_changes)
# Build simulation from appropriate source
if situation is not None:
self.build_from_situation(situation)
Expand All @@ -191,6 +201,14 @@ def __init__(
raise ValueError(f"Unsupported dataset type: {dataset.__class__}")
self.built_from_dataset = situation is None

# Parameter changes applied after data load leave the data as built
# (a changed economic assumption does not re-uprate it), but come
# before the steps below that read the parameters: the Universal
# Credit rebalancing inputs and the structural reforms switched on by
# parameters.
if scenario is not None and not scenario.applied_before_data_load:
self.apply_scenario_parameters(scenario)

# Universal Credit reform (July 2025). Needs closer integration in the baseline,
# but adding here for ease of toggling on/off via the 'active' parameter.
from policyengine_uk.scenarios import universal_credit_july_2025_reform
Expand Down Expand Up @@ -236,8 +254,6 @@ def __init__(
if scenario is not None and not scenario.applied_before_data_load:
if scenario.simulation_modifier is not None:
scenario.simulation_modifier(self)
if scenario.parameter_changes is not None:
self.apply_parameter_changes(scenario.parameter_changes)

def delete_arrays(self, variable: str, period: Period = None) -> None:
super().delete_arrays(variable, period)
Expand All @@ -263,28 +279,42 @@ def get_known_variables(self):
return variables

def apply_parameter_changes(self, changes: dict):
for parameter in changes:
check_parameter_not_removed(canonicalize_lsr_parameter_path(parameter))
self.tax_benefit_system.reset_parameters()

for parameter in changes:
canonical_parameter = canonicalize_lsr_parameter_path(parameter)
p: Parameter = self.tax_benefit_system.parameters.get_child(
canonical_parameter
"""Rebuild the parameters with ``Scenario.parameter_changes``.

Each change is written onto the statutory timeline (see
``policyengine_uk.utils.dated_reforms`` for how its period keys are
read) and the parameters are processed as current law is.
"""
self.apply_parameter_assignments(
read_parameter_changes(_canonical_parameter_paths(changes))
)

def apply_scenario_parameters(self, scenario: Scenario) -> None:
"""Rebuild the parameters with a scenario's parameter changes.

Takes ``parameter_changes`` and then each of the scenario's
``dated_reforms`` in order, in one rebuild. Changes a dated reform
leaves to the annualised parameters (single-day keys and paths the
statutory tree does not define) are applied by the scenario's
modifier instead.
"""
assignments = []
if scenario.parameter_changes:
assignments += read_parameter_changes(
_canonical_parameter_paths(scenario.parameter_changes)
)
if isinstance(changes[parameter], dict):
# Time-period specific changes. A bare year names the UK
# fiscal year, so the override covers 6 April to 5 April and
# survives fiscal-year conversion at full strength.
for time_period in changes[parameter]:
p.update(
period=uk_fiscal_year_period(time_period),
value=changes[parameter][time_period],
)
else:
p.update(period="year:2000:100", value=changes[parameter])
assignments += scenario.dated_assignments(order_start=len(assignments))
self.apply_parameter_assignments(assignments)

self.tax_benefit_system.process_parameters()
def apply_parameter_assignments(self, assignments: list) -> None:
"""Rebuild the parameters with changes on the statutory timeline."""
if not assignments:
return
self.tax_benefit_system.apply_parameter_assignments(assignments)
if getattr(self, "populations", None) is not None:
# Building from a situation or dataset can calculate values;
# recompute them under the rebuilt parameters.
self._invalidate_all_caches()

def build_from_situation(self, situation: Dict) -> None:
"""Build simulation from a situation dictionary.
Expand Down
Loading
Loading