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
4 changes: 4 additions & 0 deletions changelog_entry.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- bump: minor
changes:
added:
- Scottish Child Payment baby bonus reform.
25 changes: 19 additions & 6 deletions docs/book/validation/student-loan-repayments.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,14 @@
"outputs": [],
"source": [
"# Plan distribution (weighted)\n",
"plan_names = {0: \"None\", 1: \"Plan 1\", 2: \"Plan 2\", 3: \"Postgraduate\", 4: \"Plan 4\", 5: \"Plan 5\"}\n",
"plan_names = {\n",
" 0: \"None\",\n",
" 1: \"Plan 1\",\n",
" 2: \"Plan 2\",\n",
" 3: \"Postgraduate\",\n",
" 4: \"Plan 4\",\n",
" 5: \"Plan 5\",\n",
"}\n",
"for plan_id, name in plan_names.items():\n",
" count = weight[plan == plan_id].sum() / 1e6\n",
" print(f\"{name}: {count:.2f}m people\")"
Expand Down Expand Up @@ -119,16 +126,22 @@
"\n",
"if has_reported.sum() > 0:\n",
" # Correlation\n",
" correlation = np.corrcoef(reported[has_reported], modelled[has_reported])[0, 1]\n",
" correlation = np.corrcoef(reported[has_reported], modelled[has_reported])[\n",
" 0, 1\n",
" ]\n",
" print(f\"Correlation (people with reported > 0): {correlation:.3f}\")\n",
" \n",
"\n",
" # Match rate\n",
" both_positive = (reported > 0) & (modelled > 0)\n",
" match_rate = both_positive.sum() / has_reported.sum() * 100\n",
" print(f\"People with both reported & modelled > 0: {match_rate:.1f}% of reporters\")\n",
" \n",
" print(\n",
" f\"People with both reported & modelled > 0: {match_rate:.1f}% of reporters\"\n",
" )\n",
"\n",
" # Mean values\n",
" print(f\"\\nMean reported (reporters): £{reported[has_reported].mean():,.0f}\")\n",
" print(\n",
" f\"\\nMean reported (reporters): £{reported[has_reported].mean():,.0f}\"\n",
" )\n",
" print(f\"Mean modelled (reporters): £{modelled[has_reported].mean():,.0f}\")\n",
" print(f\"Mean income (reporters): £{income[has_reported].mean():,.0f}\")"
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
description: Additional weekly amount for Scottish Child Payment based on child age.
brackets:
- threshold:
values:
0001-01-01: 0
amount:
values:
0001-01-01: 12.85
- threshold:
values:
0001-01-01: 1
amount:
values:
0001-01-01: 0
metadata:
type: single_amount
threshold_unit: year
amount_unit: currency-GBP
period: week
label: Scottish Child Payment baby bonus by age
reference:
- title: Scottish Government - Scottish Child Payment
href: https://www.gov.scot/policies/social-security/scottish-child-payment/
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
description: Whether the Scottish Child Payment baby bonus reform is in effect.
values:
0001-01-01: false
metadata:
unit: bool
period: year
label: Scottish Child Payment baby bonus reform in effect
2 changes: 2 additions & 0 deletions policyengine_uk/reforms/reforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
disable_simulated_benefits,
)
from .policyengine.adjust_budgets import adjust_budgets
from .scotland import create_scottish_child_payment_reform
from policyengine_core.model_api import *
from policyengine_core import periods

Expand All @@ -15,6 +16,7 @@ def create_structural_reforms_from_parameters(parameters, period):
create_household_based_hitc_reform(parameters, period),
disable_simulated_benefits(parameters, period),
adjust_budgets(parameters, period),
create_scottish_child_payment_reform(parameters, period),
]
reforms = tuple(filter(lambda x: x is not None, reforms))

Expand Down
1 change: 1 addition & 0 deletions policyengine_uk/reforms/scotland/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .scottish_child_payment_reform import create_scottish_child_payment_reform
176 changes: 176 additions & 0 deletions policyengine_uk/reforms/scotland/scottish_child_payment_reform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
from policyengine_uk.model_api import *
from policyengine_core.periods import period as period_


def create_scottish_child_payment_baby_bonus_reform() -> Reform:
"""
Reform that implements SCP Premium for under-ones.

Policy: Children under 1 receive a FIXED £40/week total payment.
Children 1+ receive the standard SCP rate (inflates with inflation).

This is NOT a fixed bonus added to the base - it's a fixed total amount.
As the base SCP rate inflates, the "bonus" for under-1s effectively
decreases to maintain the £40 total.

Source: Scottish Budget 2026-27
https://www.gov.scot/publications/scottish-budget-2026-2027-finance-secretarys-statement-13-january-2026-2/
"""

class scottish_child_payment(Variable):
label = "Scottish Child Payment"
documentation = (
"Scottish Child Payment provides financial support to low-income "
"families in Scotland. It is paid per eligible child to families "
"receiving qualifying benefits such as Universal Credit."
)
entity = BenUnit
definition_period = YEAR
value_type = float
unit = GBP
reference = [
"https://www.gov.scot/policies/social-security/scottish-child-payment/",
"https://www.socialsecurity.gov.scot/",
]

def formula(benunit, period, parameters):
# Check if household is in Scotland
in_scotland = (
benunit.household("country", period).decode_to_str()
== "SCOTLAND"
)

# Get SCP parameters
p = parameters(
period
).gov.social_security_scotland.scottish_child_payment
weekly_amount = p.amount

# SCP only available when amount > 0 (i.e., after Feb 2021)
scp_available = weekly_amount > 0

# Count eligible children in the benefit unit
is_eligible_child = benunit.members(
"is_scp_eligible_child", period
)
eligible_children = benunit.sum(is_eligible_child)

# Get ages for baby bonus calculation
age = benunit.members("age", period)

# Count children under 6 and 6+ for takeup rate calculation
is_child = benunit.members("is_child", period)
children_6_and_over = benunit.sum(
is_child & (age >= 6) & (age < 16)
)

# Check if receiving a qualifying benefit
qb = p.qualifying_benefits

receives_uc = (
benunit("universal_credit", period) > 0
) & qb.universal_credit
receives_ctc = (
benunit("child_tax_credit", period) > 0
) & qb.child_tax_credit
receives_wtc = (
benunit("working_tax_credit", period) > 0
) & qb.working_tax_credit
receives_income_support = (
benunit("income_support", period) > 0
) & qb.income_support
receives_jsa_income = (
benunit("jsa_income", period) > 0
) & qb.jsa_income
receives_esa_income = (
benunit("esa_income", period) > 0
) & qb.esa_income
receives_pension_credit = (
benunit("pension_credit", period) > 0
) & qb.pension_credit

receives_qualifying_benefit = (
receives_uc
| receives_ctc
| receives_wtc
| receives_income_support
| receives_jsa_income
| receives_esa_income
| receives_pension_credit
)

# SCP Premium for under-ones: Fixed £40/week total (not base + bonus)
# Policy: Children under 1 get £40/week, children 1+ get standard rate
PREMIUM_RATE_UNDER_ONE = 40.0 # £40/week fixed total

# Calculate per-child weekly amount based on age
per_child_weekly = where(
age < 1,
PREMIUM_RATE_UNDER_ONE, # £40/week for under-1s (TOTAL, not bonus)
weekly_amount, # Standard SCP rate for 1+ (inflates with inflation)
)

# Calculate total weekly payment for all eligible children
total_weekly = benunit.sum(per_child_weekly * is_eligible_child)

# Convert to annual amount
annual_amount = total_weekly * WEEKS_IN_YEAR

# Apply age-specific take-up rates in microsimulation
takeup_under_6 = p.takeup_rate.under_6
takeup_6_and_over = p.takeup_rate.age_6_and_over

has_children_6_and_over = children_6_and_over > 0
takeup_rate = where(
has_children_6_and_over, takeup_6_and_over, takeup_under_6
)

takes_up = random(benunit) < takeup_rate
is_in_microsimulation = benunit.simulation.dataset is not None
if is_in_microsimulation:
receives_payment = takes_up
else:
receives_payment = True

return (
in_scotland
* scp_available
* receives_qualifying_benefit
* receives_payment
* annual_amount
)

class reform(Reform):
def apply(self):
self.update_variable(scottish_child_payment)

return reform


def create_scottish_child_payment_reform(
parameters, period, bypass: bool = False
):
if bypass:
return create_scottish_child_payment_baby_bonus_reform()

p = parameters.gov.contrib.scotland.scottish_child_payment

# Check if reform is active in current period or next 5 years
reform_active = False
current_period = period_(period)

for i in range(5):
if p(current_period).in_effect:
reform_active = True
break
current_period = current_period.offset(1, "year")

if reform_active:
return create_scottish_child_payment_baby_bonus_reform()
else:
return None


scottish_child_payment_reform = (
create_scottish_child_payment_baby_bonus_reform()
)
9 changes: 9 additions & 0 deletions policyengine_uk/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
extend_single_year_dataset,
)
from policyengine_uk.utils.dependencies import get_variable_dependencies
from policyengine_uk.reforms import create_structural_reforms_from_parameters

from .tax_benefit_system import CountryTaxBenefitSystem

Expand Down Expand Up @@ -121,6 +122,14 @@ def __init__(

self.tax_benefit_system.reset_parameter_caches()

# Apply structural reforms based on parameters
structural_reform = create_structural_reforms_from_parameters(
self.tax_benefit_system.parameters,
period_(self.default_input_period),
)
if structural_reform is not None:
self.apply_reform(structural_reform)

self.move_values("capital_gains", "capital_gains_before_response")
self.move_values("employment_income", "employment_income_before_lsr")
self.move_values(
Expand Down
Loading