Skip to content
1 change: 1 addition & 0 deletions changelog.d/2406.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- A sum of scenarios keeps the timing of each part's modifier. A part applied before data load, such as `no_economic_assumptions`, is still applied before it when added to a reform, so `no_economic_assumptions + reform` freezes the data as well as the parameters. Likewise a macro scenario applied before data load, plus a reform, projects the data with the macro growth. Modifiers of parts applied after data load, including ones that edit the data, still run after it. A sum with a part applied before data load applies all of its parameter changes first, as `+` merges them, so the data follows them all and a modifier's edits survive. A simulation with a scenario applied before data load no longer reads or writes the `hf://` and `gs://` dataset cache, so its projection no longer reaches its baseline or later simulations. The sum of scenarios that are all applied before data load is now applied before it. A sum now calls the modifiers its operands had when it was made. Its fields still hold the whole sum, and `Scenario.parts_by_data_load()` returns what is applied before and after data load.
17 changes: 16 additions & 1 deletion docs/book/usage/scenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ for name, scenario in [
print(f"{name}: £{net_income / 12:.2f} per month")
```

A sum keeps the timing of each part's modifier. A simulation runs the modifiers of the parts set with `applied_before_data_load=True` before it builds its data, and the rest once the data is built, each in the order added. A sum with a part applied before data load applies all of its parameter changes first, before the data is built, with the precedence `+` gives them: changing the parameters rebuilds them, which would discard the edits of a modifier that has already run. The data's projected years then follow every parameter change of the sum. So `no_economic_assumptions + reform` freezes the data and the parameters and applies the reform, and a modifier that edits the data still runs once the data exists. Such a simulation builds its own copy of a `hf://` or `gs://` dataset rather than sharing the cached one, so its baseline and later simulations keep current law's projection. A sum reads `applied_before_data_load=True` only when every part that changes something is applied before data load. `Scenario.parts_by_data_load()` returns what is applied before data load and what is applied after. If you change a sum's `applied_before_data_load` or `simulation_modifier` to another value after making it, the whole sum is applied as one scenario, at the time its own `applied_before_data_load` says.

## Defining structural scenarios

Beyond simple parameter changes, you sometimes need scenarios that make structural modifications to how the tax-benefit system works. Let's walk through how to create these, using examples similar to those in PolicyEngine UK's scenarios module.
Expand Down Expand Up @@ -314,6 +316,19 @@ print(float(baseline.calculate("employment_income", 2026)[0]))
print(float(static.calculate("employment_income", 2026)[0]))
```

Add it to a reform to measure the reform without growth. The sum still applies the freeze before the data is built, so with a dataset (`Simulation(dataset=...)`) the projected years keep the first year's amounts too, and it applies the reform as usual:

```python
from policyengine_uk.model_api import Scenario

reform = Scenario.from_reform(
{"gov.hmrc.income_tax.allowances.personal_allowance.amount": {"2025.2099": 20_000}}
)
static_reform = Simulation(
situation=situation, scenario=no_economic_assumptions + reform
)
```

## Advanced scenario techniques

### Time-varying parameters
Expand Down Expand Up @@ -400,7 +415,7 @@ Some parameters hold one value per calendar year, dated 1 January, and are read

Processing also builds some parameters from others: the triple lock rate (`gov.economic_assumptions.yoy_growth.triple_lock`), the lagged and private pension series, the September CPI uprating rate and the indices. A change to one is written as soon as it is built, so everything built from it follows. `{"gov.economic_assumptions.yoy_growth.triple_lock": {"2028": 0.05}}` uprates the State Pension by 5% in April 2028, exactly as `gov.dwp.state_pension.triple_lock.outturn` set for that April does. A change to an index uprates whatever is uprated by it. The deprecated `gov.simulation.labor_supply_responses` paths change the `labour_supply_responses` parameters they copy.

Changes applied after the data is loaded (the default) leave the data as built, so a changed economic assumption does not re-uprate incomes. Dated changes (`parameter_changes` and `"start.stop"` or bare-year keys) 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. Single-day keys (other than a 1 January key on a calendar-year series, which is a dated change) and a scenario's own simulation modifier run after those steps, as they always have. Set `applied_before_data_load=True` on a scenario to project the data with changed economic assumptions too.
Changes applied after the data is loaded (the default) leave the data as built, so a changed economic assumption does not re-uprate incomes. Dated changes (`parameter_changes` and `"start.stop"` or bare-year keys) 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. Single-day keys (other than a 1 January key on a calendar-year series, which is a dated change) and a scenario's own simulation modifier run after those steps, as they always have. Set `applied_before_data_load=True` on a scenario to project the data with changed economic assumptions too. 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.

Expand Down
48 changes: 35 additions & 13 deletions policyengine_uk/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ class Simulation(CoreSimulation):
# true for a region or constituency filtered from the data. The builders
# set it, so rebuilding a simulation in place keeps it right.
built_from_dataset: bool = False
# True when part of the scenario is applied before data load, so the
# parameters the data is built with may not be current law's. Such a
# simulation neither reads nor writes the URL dataset cache.
_scenario_before_data_load: bool = False

def __init__(
self,
Expand Down Expand Up @@ -212,13 +216,21 @@ def __init__(

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

# What the scenario applies before the data is built and after it. A
# scenario is one or the other; a sum of scenarios applied at
# different times runs each part's modifier at the part's own time.
before_data_load, after_data_load = (
scenario.parts_by_data_load() if scenario is not None else (None, None)
)
self._scenario_before_data_load = before_data_load is not None

# 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 before_data_load is not None:
self.apply_scenario_parameters(before_data_load)
if before_data_load.simulation_modifier is not None:
before_data_load.simulation_modifier(self)
self._refresh_capital_gains_projection()
# Build simulation from appropriate source
if situation is not None:
Expand All @@ -243,8 +255,8 @@ def __init__(
# 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)
if after_data_load is not None:
self.apply_scenario_parameters(after_data_load)

# Universal Credit reform (July 2025). Needs closer integration in the baseline,
# but adding here for ease of toggling on/off via the 'active' parameter.
Expand Down Expand Up @@ -297,9 +309,9 @@ def __init__(

self.calculated_periods = []

if scenario is not None and not scenario.applied_before_data_load:
if scenario.simulation_modifier is not None:
scenario.simulation_modifier(self)
if after_data_load is not None:
if after_data_load.simulation_modifier is not None:
after_data_load.simulation_modifier(self)
self._refresh_capital_gains_projection()
self._capture_cgt_supplied_inputs()
self._cgt_warning_enabled = True
Expand Down Expand Up @@ -592,6 +604,15 @@ def capital_gains_projection_metadata(self) -> dict:
}

def _dataset_cache_key(self, source: str):
"""The URL dataset cache key for a source, or None not to cache.

A cached dataset is already projected to later years, with current
law's economic assumptions unless a scenario applied before data load
changed them, so a simulation with such a scenario neither reads nor
writes the cache.
"""
if self._scenario_before_data_load:
return None
if self.tax_benefit_system.capital_gains_projection is None:
return source
metadata = self.capital_gains_projection_metadata
Expand Down Expand Up @@ -688,7 +709,7 @@ def build_from_dataset_source(
return
if dataset_source.startswith("gs://"):
cache_key = self._dataset_cache_key(dataset_source)
if cache_key in _url_dataset_cache:
if cache_key is not None and cache_key in _url_dataset_cache:
multi_year_dataset = _url_dataset_cache[cache_key]
self.build_from_multi_year_dataset(multi_year_dataset)
self._restore_cgt_extension_provenance(multi_year_dataset)
Expand Down Expand Up @@ -727,8 +748,9 @@ def build_from_file(
# Pre-encode string enum columns to int16 once before caching so
# subsequent loads skip the expensive astype(str) + searchsorted path.
_pre_encode_enum_columns(multi_year_dataset, self.tax_benefit_system)
if cache_key is not None:
_url_dataset_cache[self._dataset_cache_key(cache_key)] = multi_year_dataset
key = self._dataset_cache_key(cache_key) if cache_key is not None else None
if key is not None:
_url_dataset_cache[key] = multi_year_dataset

self.build_from_multi_year_dataset(multi_year_dataset)
self._restore_cgt_extension_provenance(multi_year_dataset)
Expand All @@ -749,7 +771,7 @@ def build_from_url(self, url: str) -> None:
# Return early from in-memory cache if available: skips HDF5 reading,
# uprating and enum encoding (~2.2s on the first load).
cache_key = self._dataset_cache_key(url)
if cache_key in _url_dataset_cache:
if cache_key is not None and cache_key in _url_dataset_cache:
multi_year_dataset = _url_dataset_cache[cache_key]
self.build_from_multi_year_dataset(multi_year_dataset)
self._restore_cgt_extension_provenance(multi_year_dataset)
Expand Down
Loading
Loading