Skip to content
Merged
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: 5 additions & 0 deletions changelog.d/2426.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- Fixed standalone dated-reform modifiers silently dropping parameter changes, warned when UC rebalancing supersedes an LCWRA amount reform, and restored new-claimant draws for data-built core simulations.
- Preserved standalone modifier order across repeated and reordered reforms and retained no-op behavior for already-effective reforms on copies.
- Deferred LCWRA override warnings until standalone tuple and sum modifiers finish, with one warning per amount-reform application.
- Preserved callback-only Scenario.apply compatibility for lightweight simulation objects without a tax-benefit system.
- Replayed mixed statutory and legacy modifiers in their requested order, restricting composite no-ops to exact latest write sequences with current values.
2 changes: 2 additions & 0 deletions docs/book/usage/scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,8 @@ Processing also builds some parameters from others: the triple lock rate (`gov.e
A dataset's later years are projected from its first by the economic assumptions as the simulation is built. Dated changes that change what that projection reads (a growth rate, say, through `parameter_changes`, `"start.stop"` or bare-year keys, or a 1 January key on a calendar-year series) are written before the data is built, so incomes, rents and council tax follow them, as with `applied_before_data_load=True`. Other dated changes come just after the data is built, before the steps that read parameters while the simulation is built: the Universal Credit rebalancing awards and the structural reforms that parameters switch on. Other single-day keys and a scenario's own simulation modifier run after those steps, as they always have, and leave the data as built. Datasets loaded from `hf://` or `gs://` are cached once projected, under current law only: a simulation whose projection inputs differ builds its own, so a scenario never reaches its baseline or a later simulation. A sum with a part applied before data load applies all its parameter changes before the data is built (see "Combining scenarios" above).

All of this applies when the scenario or reform is passed to `Simulation(...)`. `Scenario.apply(simulation)` on a simulation that is already built edits its annualised values instead, as it always has: a `"2027-04-06.2028-04-05"` key there overrides those dates of the fiscal-year values, so it does not set fiscal year 2027, and the model logs a warning saying so. Pass the scenario when constructing the simulation for dated measures.
The modifier returned by `Scenario.from_reform` behaves the same way on a built simulation for changes that are no longer effective, and raises if an external override key contains no 1 January annual value.
A pure statutory or pure legacy reform can be a no-op on a copy, clone or branch only when its complete write sequence matches the latest recorded writes on its paths, the other log has no writes on those paths, and its values remain current. Mixed statutory and legacy composites always replay in their requested order; this can append duplicate override-log entries. An external key that contains no 1 January annual value raises with its path and key.

### Creating complex scenarios with simulation modifiers

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
description: Limited capability for work and work-related activity (LCWRA) element amount under Universal Credit. From April 2026 this is the amount for claimants other than pre-2026, severe conditions criteria or terminally ill claimants, which Universal Credit Act 2025 s. 3 freezes for 2026-27 to 2029-30.
description: Limited capability for work and work-related activity (LCWRA) element amount under Universal Credit. From April 2026 this is the amount for claimants other than pre-2026, severe conditions criteria or terminally ill claimants, which Universal Credit Act 2025 s. 3 freezes for 2026-27 to 2029-30. In years when the rebalancing modifier runs, the rebalancing protected_health_element and new_claimant_health_element parameters set the amount paid, so a health element reform for those years changes those parameters.
values:
2016-04-10: 315.6
2017-04-10: 318.76
Expand Down
5 changes: 4 additions & 1 deletion policyengine_uk/scenarios/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@
from .pip_reform import reform_pip_phase_in
from .reindex_benefit_cap import reindex_benefit_cap
from .repeal_two_child_limit import repeal_two_child_limit
from .uc_reform import universal_credit_july_2025_reform
from .uc_reform import (
UCHealthElementOverrideWarning,
universal_credit_july_2025_reform,
)
66 changes: 64 additions & 2 deletions policyengine_uk/scenarios/uc_reform.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from policyengine_uk.model_api import Scenario
from policyengine_uk import Microsimulation
from policyengine_uk.utils.data_source import built_from_data
from policyengine_uk.data.economic_assumptions import PROJECTION_END_YEAR
from policyengine_uk.utils.supplied_inputs import set_model_value
import numpy as np
import warnings
from policyengine_core.periods import instant


# Universal Credit Act 2025: the first tax year of the rebalancing (2026-27,
Expand Down Expand Up @@ -127,6 +130,64 @@ def _protected_existing_health_element_monthly(


NEW_CLAIMANT_STATUS = "uc_receives_new_claimant_health_element"
LCWRA_AMOUNT = "gov.dwp.universal_credit.elements.disabled.amount"


class UCHealthElementOverrideWarning(UserWarning):
"""An LCWRA amount reform is superseded by the rebalancing inputs."""


def warn_shadowed_lcwra_reform(sim) -> None:
"""Warn once when a reformed amount is read in an active rebalancing year.

Statutory changes are sampled when annualising the LCWRA parameter;
overrides of the built parameters are read at 1 January. Construction
and Scenario.apply defer this check until all their changes have run,
so a reform that also switches rebalancing off does not warn.
"""
from policyengine_uk.utils.dated_reforms import legacy_override_bounds

if getattr(sim, "_defer_uc_health_warning", False) or getattr(
sim, "_uc_health_amount_warning_emitted", False
):
return
system = getattr(sim, "tax_benefit_system", None)
if system is None:
return
assignments = [
change
for change in getattr(system, "dated_assignments", ())
if change.path == LCWRA_AMOUNT
]
override_windows = [
legacy_override_bounds(change, system.parameters)
for change in getattr(system, "legacy_overrides", ())
if change.path == LCWRA_AMOUNT
]
if not assignments and not override_windows:
return
rebalancing = system.parameters.gov.dwp.universal_credit.rebalancing
years = []
for year in REBALANCING_YEARS:
if not rebalancing.active(year):
continue
statutory_sample = instant(f"{year}-{FISCAL_YEAR_SAMPLE_DATE}")
annual_sample = instant(f"{year}-01-01")
if any(
change.start <= statutory_sample <= change.stop for change in assignments
) or any(start <= annual_sample <= stop for start, stop in override_windows):
years.append(year)
if years:
warnings.warn(
f"A reform to {LCWRA_AMOUNT} is superseded in "
f"{', '.join(map(str, years))} by Universal Credit rebalancing inputs. "
"For a health element reform in these years change "
"gov.dwp.universal_credit.rebalancing.protected_health_element and "
"gov.dwp.universal_credit.rebalancing.new_claimant_health_element.",
UCHealthElementOverrideWarning,
stacklevel=2,
)
sim._uc_health_amount_warning_emitted = True


def _new_claimant_health_element_status(
Expand Down Expand Up @@ -163,12 +224,12 @@ def add_universal_credit_reform(sim: Microsimulation):
# 2029-30, held after that (an assumption; see the parameter).
new_claimant_share = rebalancing.new_claimant_share
new_claimant_health_element = rebalancing.new_claimant_health_element
built_from_data = getattr(sim, "built_from_dataset", False)
is_data = built_from_data(sim)
for year in REBALANCING_YEARS:
if not rebalancing.active(year):
continue
is_post_2025_claimant = _new_claimant_health_element_status(
sim, year, uc_seed < new_claimant_share(year), built_from_data
sim, year, uc_seed < new_claimant_share(year), is_data
)
# Copy: calculate returns the simulation's cached array, and the writes
# below must reach the cache only through set_input.
Expand All @@ -191,6 +252,7 @@ def add_universal_credit_reform(sim: Microsimulation):
# The standard allowance uplift is applied through the allowance's
# uprating index (rebalancing/create_standard_allowance_uprating.py), so a
# reform to the amount itself is used as given.
warn_shadowed_lcwra_reform(sim)


universal_credit_july_2025_reform = Scenario(
Expand Down
12 changes: 12 additions & 0 deletions policyengine_uk/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ def __init__(
capital_gains_projection: Optional common baseline gains projection,
shared with the reform's baseline. Omit to retain GDP per capita.
"""
self._defer_uc_health_warning = True
self._cgt_input_provenance = {}
self._cgt_defaulted_periods = set()
self._cgt_calculation_depth = 0
Expand All @@ -253,6 +254,10 @@ def __init__(

if reform is not None:
scenario = Scenario.from_reform(reform)
# Only a construction-time reference: preserve the existing reading
# of this scenario's single-day leftovers while rejecting unreadable
# overrides from a user modifier's separate from_reform call.
self._building_dated_reforms = scenario.dated_reforms if scenario else None

self.branch_name = "default"
self.invalidated_caches = set()
Expand Down Expand Up @@ -369,6 +374,8 @@ def __init__(
else:
self.baseline = self.clone()
self.baseline._cgt_warning_enabled = False
self.baseline._defer_uc_health_warning = False
self.baseline._building_dated_reforms = None

self.calculated_periods = []

Expand All @@ -378,6 +385,11 @@ def __init__(
self._refresh_capital_gains_projection()
self._capture_cgt_supplied_inputs()
self._cgt_warning_enabled = True
from policyengine_uk.scenarios.uc_reform import warn_shadowed_lcwra_reform

self._defer_uc_health_warning = False
self._building_dated_reforms = None
warn_shadowed_lcwra_reform(self)

def _refresh_capital_gains_projection(self) -> None:
projection = self.tax_benefit_system.capital_gains_projection
Expand Down
Loading
Loading