Skip to content
Open
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
1 change: 1 addition & 0 deletions changelog.d/1800.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Structural reform switches now follow each calculation period, including future Scottish Child Payment changes and active-year reported benefit inputs. Bare-year switch keys in reform dictionaries and Scenario `parameter_changes` apply only to the named year. Original formulas and genuine supplied inputs apply outside each reform interval.
22 changes: 22 additions & 0 deletions policyengine_uk/reforms/conservatives/household_based_hitc.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,25 @@ def create_household_based_hitc_reform(parameters, period):
return make_cb_hitc_household_based
else:
return None


def create_period_aware_household_hitc_reform(parameters, period):
from policyengine_uk.reforms.reforms import (
parameter_ever_true,
apply_period_aware_reform,
)

path = "gov.contrib.conservatives.cb_hitc_household"
if not parameter_ever_true(parameters, path):
return None

def active(root, year):
return root(year).gov.contrib.conservatives.cb_hitc_household

class reform(Reform):
def apply(self):
apply_period_aware_reform(
self, make_cb_hitc_household_based, ("CB_HITC",), active
)

return reform
90 changes: 86 additions & 4 deletions policyengine_uk/reforms/cps/marriage_tax_reforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
def create_expanded_ma_reform(
max_child_age: Optional[int] = None,
child_education_levels: Optional[List[str]] = None,
_parameter_driven: bool = False,
) -> Reform:
class meets_expanded_ma_conditions(Variable):
label = "Qualifies for an expanded Marriage Allowance"
Expand All @@ -19,9 +20,15 @@ def formula(person, period):
# couple) under the age limit, or meeting the education condition.
benunit = person.benunit
is_couple_child = ~person("is_claimant_or_partner", period)
if max_child_age is not None:
child_age_limit = max_child_age
if _parameter_driven:
cap = person.simulation.tax_benefit_system.parameters(
period
).gov.contrib.cps.marriage_tax_reforms.expanded_ma.max_child_age
child_age_limit = cap if cap > 0 else None
if child_age_limit is not None:
child_meets_age_condition = is_couple_child & (
person("age", period) < max_child_age
person("age", period) < child_age_limit
)
return benunit.any(child_meets_age_condition)
if child_education_levels is not None:
Expand Down Expand Up @@ -105,6 +112,7 @@ def apply(self):
def create_marriage_neutral_income_tax_reform(
max_child_age: Optional[int] = None,
child_education_levels: Optional[List[str]] = None,
_parameter_driven: bool = False,
) -> Reform:
class meets_ma_neutral_tax_conditions(Variable):
label = "Qualifies for an expanded Marriage Allowance"
Expand All @@ -117,9 +125,15 @@ def formula(person, period):
# couple) under the age limit, or meeting the education condition.
benunit = person.benunit
is_couple_child = ~person("is_claimant_or_partner", period)
if max_child_age is not None:
child_age_limit = max_child_age
if _parameter_driven:
cap = person.simulation.tax_benefit_system.parameters(
period
).gov.contrib.cps.marriage_tax_reforms.marriage_neutral_it.max_child_age
child_age_limit = cap if cap > 0 else None
if child_age_limit is not None:
child_meets_age_condition = is_couple_child & (
person("age", period) < max_child_age
person("age", period) < child_age_limit
)
return benunit.any(child_meets_age_condition)
if child_education_levels is not None:
Expand Down Expand Up @@ -259,3 +273,71 @@ def create_marriage_tax_reform(parameters, period):
# applies to every married couple regardless of child presence.
expanded_ma_reform = create_expanded_ma_reform()
marriage_neutral_it_reform = create_marriage_neutral_income_tax_reform()


def create_period_aware_marriage_tax_reform(parameters, period):
"""Install only families with a real activation in their finite history.

This constructor dispatch differs from the public period-only factory:
an available route still chooses its policy separately in each year.
"""
from policyengine_uk.reforms.reforms import (
finite_parameter_activation,
apply_period_aware_reform,
)

prefix = "gov.contrib.cps.marriage_tax_reforms."
paths = (
"expanded_ma.remove_income_condition",
"expanded_ma.ma_rate",
"expanded_ma.max_child_age",
"marriage_neutral_it.neutralise_income_tax",
"marriage_neutral_it.max_child_age",
)
current = [parameters.get_child(prefix + path) for path in paths]
baseline = [parameters.baseline.get_child(prefix + path) for path in paths]
expanded = finite_parameter_activation(
current + baseline, lambda values: bool(values[0]) or values[1] != values[6]
)
neutral = finite_parameter_activation(
current + baseline, lambda values: bool(values[3])
)
if not expanded and not neutral:
return None

def expanded_active(root, year):
p = root(year).gov.contrib.cps.marriage_tax_reforms.expanded_ma
return (
p.remove_income_condition
or p.ma_rate
!= root.baseline(
year
).gov.contrib.cps.marriage_tax_reforms.expanded_ma.ma_rate
)

def neutral_active(root, year):
return root(
year
).gov.contrib.cps.marriage_tax_reforms.marriage_neutral_it.neutralise_income_tax

class reform(Reform):
def apply(self):
if expanded:
apply_period_aware_reform(
self,
create_expanded_ma_reform(_parameter_driven=True),
(
"meets_marriage_allowance_income_conditions",
"marriage_allowance",
),
expanded_active,
)
if neutral:
apply_period_aware_reform(
self,
create_marriage_neutral_income_tax_reform(_parameter_driven=True),
("net_income",),
neutral_active,
)

return reform
65 changes: 45 additions & 20 deletions policyengine_uk/reforms/policyengine/disable_simulated_benefits.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,26 @@
YEARS_IN_FUTURE = 10


def disable_simulated_benefits(parameters, period):
if parameters(period).gov.contrib.policyengine.disable_simulated_benefits:
def disable_simulated_benefits(parameters, period, _period_aware=False):
if _period_aware:
from policyengine_uk.reforms.reforms import parameter_ever_true

available = parameter_ever_true(
parameters, "gov.contrib.policyengine.disable_simulated_benefits"
)
else:
available = parameters(
period
).gov.contrib.policyengine.disable_simulated_benefits
if available:

class DisableSimulatedBenefits(Reform):
"""Use the dataset year's reported benefit amounts in every year.

Parameter-driven construction stores only active calculation years
and temporarily removes its owned inputs for an inactive query.
Explicit application keeps this reform's existing ten-year mode.

Each listed benefit is set, in the dataset year and the nine years
after it, to its amount reported in the dataset year. The amount
is not uprated, while most of the *_reported variables are.
Expand Down Expand Up @@ -144,6 +158,15 @@ def apply(self):
system = simulation.tax_benefit_system
time_period = int(str(simulation.dataset.time_period)[:4])
years = range(time_period, time_period + YEARS_IN_FUTURE)
if _period_aware:
years = tuple(
year
for year in years
if system.parameters(
year
).gov.contrib.policyengine.disable_simulated_benefits
)
prepared = {}

def reported(variable, members=None):
"""A benefit's reported amount in the dataset year, for
Expand Down Expand Up @@ -179,8 +202,7 @@ def reported(variable, members=None):
entity = system.variables[variable].entity.key
count = simulation.populations[entity].count
values[variable] = np.zeros(count, dtype=bool)
for target, value in values.items():
simulation.set_input(target, year, value)
prepared[year] = values

# Where a household lives is read in each year, as the model
# reads it (country is a YEAR variable). The reported amounts
Expand Down Expand Up @@ -215,15 +237,11 @@ def reported(variable, members=None):
paid_as_csp = households.project(in_scotland[year]) & (
year >= CARER_SUPPORT_PAYMENT_FROM
)
simulation.set_input(
"carers_allowance",
year,
np.where(paid_as_csp, 0, reported_care),
prepared[year]["carers_allowance"] = np.where(
paid_as_csp, 0, reported_care
)
simulation.set_input(
"carer_support_payment",
year,
np.where(paid_as_csp, reported_care, 0),
prepared[year]["carer_support_payment"] = np.where(
paid_as_csp, reported_care, 0
)

# The survey reports one winter heating payment: FRS benefit
Expand All @@ -250,15 +268,22 @@ def reported(variable, members=None):
for year in years:
scheme = system.parameters(year).gov.social_security_scotland
paid_as_pawhp = in_scotland[year] & scheme.pawhp.active
simulation.set_input(
"winter_fuel_allowance",
year,
np.where(paid_as_pawhp, 0, reported_winter_heating),
prepared[year]["winter_fuel_allowance"] = np.where(
paid_as_pawhp, 0, reported_winter_heating
)
simulation.set_input(
"pawhp",
year,
np.where(paid_as_pawhp, reported_winter_heating, 0),
prepared[year]["pawhp"] = np.where(
paid_as_pawhp, reported_winter_heating, 0
)

# Read all source reports and current-year mappings before
# storing anything that could become a carried input.
if _period_aware:
simulation._install_reported_benefit_inputs(
prepared, time_period, YEARS_IN_FUTURE
)
else:
for year, values in prepared.items():
for target, value in values.items():
simulation.set_input(target, year, value)

return DisableSimulatedBenefits
101 changes: 94 additions & 7 deletions policyengine_uk/reforms/reforms.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
from .cps import create_marriage_tax_reform
from .conservatives import create_household_based_hitc_reform
from .cps.marriage_tax_reforms import create_period_aware_marriage_tax_reform
from .conservatives.household_based_hitc import (
create_period_aware_household_hitc_reform,
)
from .policyengine import (
disable_simulated_benefits,
)
from .policyengine.adjust_budgets import adjust_budgets
from .scotland import create_scottish_child_payment_reform
from .scotland.scottish_child_payment_reform import (
create_period_aware_scottish_child_payment_reform,
)
from policyengine_core.model_api import *
from policyengine_core import periods


def create_structural_reforms_from_parameters(parameters, period):
period = periods.period(period)
reforms = [
create_marriage_tax_reform(parameters, period),
create_household_based_hitc_reform(parameters, period),
disable_simulated_benefits(parameters, period),
create_period_aware_marriage_tax_reform(parameters, period),
create_period_aware_household_hitc_reform(parameters, period),
disable_simulated_benefits(parameters, period, _period_aware=True),
adjust_budgets(parameters, period),
create_scottish_child_payment_reform(parameters, period),
create_period_aware_scottish_child_payment_reform(parameters, period),
]
reforms = tuple(filter(lambda x: x is not None, reforms))

Expand All @@ -26,3 +30,86 @@ def apply(self):
reform.apply(self)

return combined_reform


def finite_parameter_activation(leaves, predicate):
"""Inspect actual effective points, including raw early-year sentinels.

Sampling starts only where every participating leaf has history. A missing
or null value there is unsupported, rather than an implicit off switch.
"""
histories = [leaf.values_list for leaf in leaves]
if any(not history for history in histories):
raise ValueError("Structural reform parameters require effective history")
first_covered = max(
min(value.instant_str for value in history) for history in histories
)
points = sorted({value.instant_str for history in histories for value in history})
available = False
for point in points:
if point < first_covered:
continue
# Parameter._get_at_instant accepts the stored string directly. The
# public callable would try to turn year 0000 into a datetime.date.
values = [leaf._get_at_instant(point) for leaf in leaves]
if any(value is None for value in values):
raise ValueError(f"Structural reform parameter has no value at {point}")
# Validate later points even after an earlier interval is active.
# An explicit null anywhere in covered history remains unsupported.
active_at_point = bool(predicate(values))
available = active_at_point or available
return available


def parameter_ever_true(parameters, path):
return finite_parameter_activation(
[parameters.get_child(path)], lambda values: bool(values[0])
)


def apply_period_aware_reform(system, reform, names, active):
"""Route each replacement through the original dated Variable API.

The contributed and original definitions must share storage and masking
metadata. Formula execution, including adds/subtracts, stays in Core.
"""
originals = {name: system.variables[name].clone() for name in names}
trial = system.clone()
reform.apply(trial)
metadata = (
"value_type",
"definition_period",
"default_value",
"defined_for",
"end",
"set_input",
"calculate_output",
"uprating",
)
for name, original in originals.items():
replacement = trial.variables[name]
if original.entity.key != replacement.entity.key or any(
getattr(original, key, None) != getattr(replacement, key, None)
for key in metadata
):
raise ValueError(f"Structural reform changes routing metadata for {name}")
reform.apply(system)
for name, original in originals.items():
replacement = system.variables[name].clone()
routed = replacement.clone()

def route(original, replacement):
def formula(population, period, parameters):
simulation = population.simulation
root = simulation.tax_benefit_system.parameters
selected = replacement if active(root, period) else original
return simulation._run_formula(selected, population, period)

return formula

starts = set(original.formulas) | set(replacement.formulas)
if not starts:
raise ValueError(f"Structural reform has no formula route for {name}")
routed.formulas.clear()
routed.formulas[min(starts)] = route(original, replacement)
system.variables[name] = routed
Loading
Loading