diff --git a/changelog.d/1852.fixed.md b/changelog.d/1852.fixed.md new file mode 100644 index 0000000000..6c0347f143 --- /dev/null +++ b/changelog.d/1852.fixed.md @@ -0,0 +1 @@ +- Keep the unimplemented Scottish Two Child Limit Payment inactive in the baseline, with explicit counterfactual tests. diff --git a/changelog.d/1871.added.md b/changelog.d/1871.added.md new file mode 100644 index 0000000000..e25c4ea57b --- /dev/null +++ b/changelog.d/1871.added.md @@ -0,0 +1 @@ +- Price supplied person-level bus journeys with regional yields, single-fare caps and proportional reform controls, preserving reported spending for legacy data. diff --git a/changelog.d/2064.fixed.md b/changelog.d/2064.fixed.md new file mode 100644 index 0000000000..6894b0495b --- /dev/null +++ b/changelog.d/2064.fixed.md @@ -0,0 +1 @@ +- Recognise housing-association tenants as renting and share the rental tenure predicate between households and benefit units. diff --git a/changelog.d/2065.fixed.md b/changelog.d/2065.fixed.md new file mode 100644 index 0000000000..8b4bfcfe52 --- /dev/null +++ b/changelog.d/2065.fixed.md @@ -0,0 +1 @@ +- Preserve the independent Child Benefit claim gate, restore opted-out claimants' payments when a reform lowers the charge below a configurable behavioural threshold, and handle cliff charges without division warnings. diff --git a/docs/engineering/bus-fares.md b/docs/engineering/bus-fares.md new file mode 100644 index 0000000000..57547f47ac --- /dev/null +++ b/docs/engineering/bus-fares.md @@ -0,0 +1,138 @@ +# Bus fares from journeys + +The model prices supplied person-level local-bus journeys from 2024 onwards. +`person_bus_fare_spending` sums London and other-local-bus spending; +`bus_fare_spending` sums it across household members. These outputs describe +spending. `transport_consumption` already includes bus spending, so consumers +must not add it to the consumption total again. This implementation does not +change transport consumption, disposable income or government subsidy totals. + +## Inputs and fallback + +The Microcosm `nts_bus_travel` stage supplies `bus_in_london_trips`, +`other_local_bus_trips`, their sum `local_bus_trips`, `bus_pass_eligible` and +`local_bus_single_fare_share` at Person level. Trips are annual quantities. +A supplied zero counts as journey data. A cached default does not. If only +`local_bus_trips` is supplied, London residents' trips use the London series +and other residents' trips use the other-local-bus series. When either split +series is supplied, that split takes precedence and the absent series is zero. +Supply consistent splits for cross-boundary travel. + +The model treats a person flagged `bus_pass_eligible` as travelling free, +matching Microcosm's pricing assumption. The dataset owns that eligibility +flag; the model does not infer concessionary eligibility from age or change +it when a concessionary scheme is reformed. Supply `bus_pass_eligible` alongside +journey inputs, including in household calculators; an omitted flag defaults to +false and the person pays fares even if eligible for a statutory concession. +For fare-paying passengers, +`local_bus_single_fare_share` is the share of boardings bought as singles, +bounded to [0, 1]. Its fallback is 0.70, the 2024 NTS other-local-bus estimate. +An optional `local_bus_uncapped_single_fare` overrides the representative +uncapped fare described below. Negative quantities and fares price at zero. + +On loading a UK `Simulation`, a supplied household `bus_fare_spending` moves +to `bus_fare_spending_reported`. The stored total therefore cannot freeze the +journey formula in a reform. Without journey inputs, the model retains this +reported total and allocates it using `fare_allocation_weight_by_age`. +Reported totals retain CPI uprating. Wales and unknown regions retain the +reported total even when journey inputs exist, following Microcosm's decision +to leave areas without published receipts unpriced. Before 2024 the model +also uses this legacy route. Direct core/YAML tests can still override the +household output explicitly. + +## Published price benchmark + +Microcosm prices journeys using FY2024–25 published facts. The model uses the +same inputs. For each area, receipts `R`, boardings `B`, concessionary +boardings `C`, annual trips per resident `T` and population `P` give: + +- Fare-paying yield per boarding: `y = R / (B - C)`. +- Boardings per resident trip: `k = B / (T * P)`. + +| Area | R (£) | B | C | T | P | y (£) | k | +|---|---:|---:|---:|---:|---:|---:|---:| +| London | 1,347,434,943.01459 | 1,821,485,000 | 497,523,765.08908 | 13.1155774642333 | 58,620,101 | 1.017729905895 | 2.369144880275 | +| England outside London | 2,069,953,713.42079 | 1,850,509,887.26131 | 519,107,608.59444 | 28.0685543170643 | 58,620,101 | 1.554716967657 | 1.124669202640 | +| Scotland | 391,000,000 | 334,000,000 | 183,599,374.01433 | 28.0685543170643 | 5,546,900 | 2.599723222144 | 2.145240985363 | +| Northern Ireland | 150,082,817.49 | 67,794,551.35 | 8,960,000 | 28.0685543170643 | 1,927,855 | 2.550929922065 | 1.252853758464 | + +Sources: [DfT BUS01 and BUS05i](https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables), +[NTS0705a](https://www.gov.uk/government/statistical-data-sets/nts07-car-ownership-and-access), +[Scottish Transport Statistics 2025, tables 2.2a and 2.8](https://www.transport.gov.scot/publication/scottish-transport-statistics-2025/chapter-2-bus-and-coach-travel/), +[NI Public Transport Statistics 2024–25, figures 3, 4 and 6](https://www.infrastructure-ni.gov.uk/publications/public-transport-statistics-northern-ireland-2024-25), +and [Microcosm's fact extraction and pricing implementation](https://github.com/PolicyEngine/microcosm/pull/954). +Population denominators use ONS mid-2024 estimates. London uses all English +residents because non-residents also travel there. Scotland and Northern +Ireland use the English other-local-bus trip rate as a proxy, as Microcosm does. +These translations include interchanges and differences between operator +boardings and resident trips; they are not counts of legs for a known trip. + +## Caps and the uncapped counterfactual + +`gov.dft.bus.fares.cap` records the national cap on participating single-ticket +services in England outside London: £2 from January 2023, £3 from January 2025, +and £2 from January through December 2027. After December 2027 it is infinite, +representing the end of the announced scheme. These dates follow the +[DfT evaluation](https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap), +[£3 scheme guidance](https://www.gov.uk/guidance/3-national-bus-fare-cap) and +[2027 announcement](https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027). +The cap does not affect the London series, Scottish or Northern Irish fares. +England outside London uses a representative participating-service assumption; +route-level participation and local lower caps require caller-supplied fares. + +The published yield already includes ticket discounts and the benchmark cap. +For an English fare-paying person's other-local-bus boardings, the model uses: + +``` +current_yield = max(0, benchmark_yield + single_share * + (annual_min(uncapped_fare, current_cap) - + annual_min(uncapped_fare, reference_cap))) +spending = trips * boardings_per_trip * current_yield +``` + +`annual_min` caps the fare in each date interval, then weights the intervals +from 6 April to 5 April, assuming uniform journeys through the year. It does +not cap a fare using the average cap. Both `fares` and `reference_fares` preserve +calendar dates. `reference_year` is 2024 and `reference_fares` holds the benchmark +schedule fixed when the policy cap changes. The source receipts use 1 April to +31 March; this approximation treats them as the corresponding model year's +benchmark. Annual Scenario changes apply to the full model fiscal year. + +The default uncapped fare is £2.748019953976. This is an aggregate assumption, +not an observed fare distribution. The FY2024–25 cap grant is £515,693,216.60. +With `F = B - C` outside London and single share `s = 0.70`, the assumption +solves `s * (u - annual_min(u, reference_cap)) = grant / F`. Since `2 < u < 3`, +the reference year has 270 days at £2 and 95 at `u`, giving: + +``` +u = 2 + grant / (F * 0.70 * 270/365) +``` + +Removing the cap therefore raises the benchmark yield from about £1.55 to +£1.94 at the representative share, matching the grant-addback calculation +in issue #1871. This assumes full grant pass-through and no demand response. +Individual shares can differ, so a dataset aggregate need not recover the +published grant. Caps at or above £2.75, including the £3 cap, have no effect +relative to uncapped travel under the default £2.748 representative fare. +The default has no fare distribution above these caps, so it cannot estimate +the savings those caps give to passengers with higher fares. Supply +person-specific `local_bus_uncapped_single_fare` values above a cap to model +its effect on those passengers. Very large reductions can reach the zero-yield +floor. + +## Other reform controls and limits + +`fare_index / prior_law_fare_index` multiplies both journey-priced and legacy +spending. Both default to one; they are neutral reform controls rather than +historical index series. Keep the denominator positive. `ridership_index` +defaults to one and multiplies journey-priced spending, leaving supplied +journey inputs intact. The model includes no demand elasticity or automatic +ridership growth. Benchmark yields and trip translations stay nominally +constant after 2024 unless changed explicitly. Dataset CPI-uprated household +bus totals do not determine prices where journeys are available. + +A proportional scenario can set `gov.dft.bus.fare_index` to 0.9 for a 10% +reduction. A cap scenario changes `gov.dft.bus.fares.cap`; leave the reference +schedule and benchmark yield unchanged. Set the cap to infinity to remove it. +The fixture-scale tests exercise both routes through UK dataset loading and +Scenario, including baseline isolation and the January cap transitions. diff --git a/myst.yml b/myst.yml index cd46773c4c..43cc808e87 100644 --- a/myst.yml +++ b/myst.yml @@ -27,6 +27,7 @@ project: - title: Policy children: - file: docs/engineering/excise-duties.md + - file: docs/engineering/bus-fares.md - file: docs/book/policy/model-baseline - file: docs/book/policy/uc-rebalancing site: @@ -47,4 +48,4 @@ site: html_css_files: - style.css execute: - execute_notebooks: off \ No newline at end of file + execute_notebooks: off diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index b4745230c7..578e74813d 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -38,6 +38,7 @@ gov.economic_assumptions.yoy_growth.obr.consumer_price_index: - afcs_reported - alcohol_and_tobacco_consumption - bus_fare_spending +- bus_fare_spending_reported - bsp_reported - carers_allowance_reported - child_benefit_reported diff --git a/policyengine_uk/parameters/gov/dft/bus/boardings_per_trip.yaml b/policyengine_uk/parameters/gov/dft/bus/boardings_per_trip.yaml new file mode 100644 index 0000000000..7c709f3d16 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/boardings_per_trip.yaml @@ -0,0 +1,26 @@ +description: Published boardings divided by resident NTS trips in 2024-25. Scottish and + Northern Irish translations use the English other-local-bus trip rate as a proxy, matching + Microcosm. London uses all England residents because visitors also use London buses. +metadata: + unit: /1 + period: year + label: bus boardings per trip + reference: + - title: DfT bus statistics, BUS01 and BUS05i, 2024-25 + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: DfT National Travel Survey, NTS0705a, 2024 + href: https://www.gov.uk/government/statistical-data-sets/nts07-car-ownership-and-access + - title: Scottish Transport Statistics 2025, tables 2.2a and 2.8 + href: https://www.transport.gov.scot/publication/scottish-transport-statistics-2025/chapter-2-bus-and-coach-travel/ + - title: Northern Ireland Public Transport Statistics 2024-25, figures 3, 4 and 6 + href: https://www.infrastructure-ni.gov.uk/publications/public-transport-statistics-northern-ireland-2024-25 + - title: Microcosm 954, published-fact extraction and pricing derivation + href: https://github.com/PolicyEngine/microcosm/pull/954 +LONDON: + '2024-01-01': 2.369144880275 +ENGLAND_OUTSIDE_LONDON: + '2024-01-01': 1.12466920264 +SCOTLAND: + '2024-01-01': 2.145240985363 +NORTHERN_IRELAND: + '2024-01-01': 1.252853758464 diff --git a/policyengine_uk/parameters/gov/dft/bus/default_single_fare_share.yaml b/policyengine_uk/parameters/gov/dft/bus/default_single_fare_share.yaml new file mode 100644 index 0000000000..85f31267cc --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/default_single_fare_share.yaml @@ -0,0 +1,13 @@ +description: Share of other-local-bus fare-paying boardings paid at the point of use + in the 2024 National Travel Survey; used only when no person share is supplied. +values: + '0001-01-01': 0.7 +metadata: + unit: /1 + period: year + label: default single fare share + reference: + - title: DfT National Travel Survey 2024 + href: https://www.gov.uk/government/statistics/national-travel-survey-2024 + - title: Microcosm 954, NTS and published-receipts pricing evidence + href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml b/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml new file mode 100644 index 0000000000..35741aa6db --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/fare_index.yaml @@ -0,0 +1,13 @@ +description: Proportional multiplier on bus fares; a neutral reform lever, not a measured + historical fare index. +values: + '0001-01-01': 1 +metadata: + unit: /1 + period: year + label: fare index + reference: + - title: DfT bus statistics data tables, BUS01 and BUS05i + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: Microcosm 954, NTS and published-receipts pricing evidence + href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/fares/cap.yaml b/policyengine_uk/parameters/gov/dft/bus/fares/cap.yaml new file mode 100644 index 0000000000..16f8528667 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/fares/cap.yaml @@ -0,0 +1,18 @@ +description: National single-bus-fare cap on participating services in England outside London. +values: + 0001-01-01: .inf + 2023-01-01: 2 + 2025-01-01: 3 + 2027-01-01: 2 + 2028-01-01: .inf +metadata: + unit: currency-GBP + period: year + label: national single bus fare cap + reference: + - title: DfT evaluation of the two-pound bus fare cap, 2023 to 2024 + href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap + - title: DfT three-pound national bus fare cap, through December 2026 + href: https://www.gov.uk/guidance/3-national-bus-fare-cap + - title: DfT two-pound bus fares from January to December 2027 + href: https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027 diff --git a/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml b/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml new file mode 100644 index 0000000000..43cc0310fe --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/fares/index.yaml @@ -0,0 +1,2 @@ +metadata: + preserve_calendar_dates: true diff --git a/policyengine_uk/parameters/gov/dft/bus/prior_law_fare_index.yaml b/policyengine_uk/parameters/gov/dft/bus/prior_law_fare_index.yaml new file mode 100644 index 0000000000..207d2f3bec --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/prior_law_fare_index.yaml @@ -0,0 +1,13 @@ +description: Reference multiplier for proportional bus-fare reforms; keep positive + and equal to one for the published price benchmark. +values: + '0001-01-01': 1 +metadata: + unit: /1 + period: year + label: prior law fare index + reference: + - title: DfT bus statistics data tables, BUS01 and BUS05i + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: Microcosm 954, NTS and published-receipts pricing evidence + href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml b/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml new file mode 100644 index 0000000000..79092767e7 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/reference_fares/cap.yaml @@ -0,0 +1,18 @@ +description: Fixed national cap schedule for the bus-fare yield benchmark. Read in reference_year; leave unchanged when reforming fares.cap. +values: + 0001-01-01: .inf + 2023-01-01: 2 + 2025-01-01: 3 + 2027-01-01: 2 + 2028-01-01: .inf +metadata: + unit: currency-GBP + period: year + label: benchmark single bus fare cap + reference: + - title: DfT evaluation of the two-pound bus fare cap, 2023 to 2024 + href: https://www.gov.uk/government/publications/evaluation-of-the-2-bus-fare-cap + - title: DfT three-pound national bus fare cap, through December 2026 + href: https://www.gov.uk/guidance/3-national-bus-fare-cap + - title: DfT two-pound bus fares from January to December 2027 + href: https://www.gov.uk/government/speeches/2-bus-fares-from-january-2027 diff --git a/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml b/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml new file mode 100644 index 0000000000..43cc0310fe --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/reference_fares/index.yaml @@ -0,0 +1,2 @@ +metadata: + preserve_calendar_dates: true diff --git a/policyengine_uk/parameters/gov/dft/bus/reference_year.yaml b/policyengine_uk/parameters/gov/dft/bus/reference_year.yaml new file mode 100644 index 0000000000..c166df37d5 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/reference_year.yaml @@ -0,0 +1,13 @@ +description: Fiscal year of the published bus-fare yield benchmark, used only to price + the reference single-fare cap. +values: + '0001-01-01': 2024 +metadata: + unit: year + period: year + label: reference year + reference: + - title: DfT bus statistics data tables, BUS01 and BUS05i + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: Microcosm 954, NTS and published-receipts pricing evidence + href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/ridership_index.yaml b/policyengine_uk/parameters/gov/dft/bus/ridership_index.yaml new file mode 100644 index 0000000000..5de3548629 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/ridership_index.yaml @@ -0,0 +1,13 @@ +description: Multiplier on supplied local-bus journeys; held at one without an explicit + ridership scenario. +values: + '0001-01-01': 1 +metadata: + unit: /1 + period: year + label: ridership index + reference: + - title: DfT bus statistics data tables, BUS01 and BUS05i + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: Microcosm 954, NTS and published-receipts pricing evidence + href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml new file mode 100644 index 0000000000..1730a733c3 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/uncapped_single_fare.yaml @@ -0,0 +1,18 @@ +description: Representative uncapped single fare inferred from the 2024-25 fare-cap grant, + fare-paying boardings and a 70 percent single-ticket share. Assumes no demand response + and full grant pass-through. This is a modelling assumption, not an observed individual + fare. Caps at or above GBP 2.75, including the GBP 3 cap, have no effect relative to + uncapped travel with this default GBP 2.748 fare. Supply person-specific + local_bus_uncapped_single_fare inputs to represent fares above those caps. + See docs/engineering/bus-fares.md. +values: + '2024-01-01': 2.748019953976 +metadata: + unit: currency-GBP + period: year + label: assumed uncapped single bus fare + reference: + - title: DfT bus statistics, BUS01 and BUS05i, 2024-25 + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: Microcosm 954, published-fact extraction and pricing derivation + href: https://github.com/PolicyEngine/microcosm/pull/954 diff --git a/policyengine_uk/parameters/gov/dft/bus/yield_per_boarding.yaml b/policyengine_uk/parameters/gov/dft/bus/yield_per_boarding.yaml new file mode 100644 index 0000000000..99a47ec245 --- /dev/null +++ b/policyengine_uk/parameters/gov/dft/bus/yield_per_boarding.yaml @@ -0,0 +1,26 @@ +description: Passenger receipts divided by fare-paying boardings in 2024-25, including + single and season tickets. This is a benchmark yield, before subsequent cap changes and + explicit fare-index reforms; held nominally constant unless reformed. +metadata: + unit: currency-GBP + period: year + label: bus yield per boarding + reference: + - title: DfT bus statistics, BUS01 and BUS05i, 2024-25 + href: https://www.gov.uk/government/statistical-data-sets/bus-statistics-data-tables + - title: DfT National Travel Survey, NTS0705a, 2024 + href: https://www.gov.uk/government/statistical-data-sets/nts07-car-ownership-and-access + - title: Scottish Transport Statistics 2025, tables 2.2a and 2.8 + href: https://www.transport.gov.scot/publication/scottish-transport-statistics-2025/chapter-2-bus-and-coach-travel/ + - title: Northern Ireland Public Transport Statistics 2024-25, figures 3, 4 and 6 + href: https://www.infrastructure-ni.gov.uk/publications/public-transport-statistics-northern-ireland-2024-25 + - title: Microcosm 954, published-fact extraction and pricing derivation + href: https://github.com/PolicyEngine/microcosm/pull/954 +LONDON: + '2024-01-01': 1.017729905895 +ENGLAND_OUTSIDE_LONDON: + '2024-01-01': 1.554716967657 +SCOTLAND: + '2024-01-01': 2.599723222144 +NORTHERN_IRELAND: + '2024-01-01': 2.550929922065 diff --git a/policyengine_uk/parameters/gov/hmrc/child_benefit/README.md b/policyengine_uk/parameters/gov/hmrc/child_benefit/README.md index e717606e1c..a5190ffabe 100644 --- a/policyengine_uk/parameters/gov/hmrc/child_benefit/README.md +++ b/policyengine_uk/parameters/gov/hmrc/child_benefit/README.md @@ -1 +1,42 @@ # Child Benefit + +`child_benefit_entitlement` is the amount before take-up and payment opt-outs. +`child_benefit` pays that entitlement only when `would_claim_child_benefit` +is true, unless the charge-driven opt-out remains active. `CB_HITC` then charges +only the benefit actually paid. + +Claiming and opting out of payment are independent inputs: + +| Would claim | Opted out | Meaning | +|---|---|---| +| false | false or true | Nonclaimant; receives no payment under a charge reform | +| true | false | Claimant receiving payment | +| true | true | Registered claimant with a charge-driven payment opt-out | + +EnhancedFRS draws the opt-out flag independently, including for nonclaimants. +That input remains supported: a false claim flag always prevents payment, +regardless of the opt-out flag. Household calculators should set +`would_claim_child_benefit` to false to represent a nonclaimant. + +[Earlier Microcosm exports](https://github.com/PolicyEngine/microcosm/blob/4163b819402d9417554e0cb4e9ff890973897c24/packages/microcosm-build/src/microcosm/build/uk_runtime/child_benefit_take_up.py#L436) +stored `claims & ~opt_out` in `would_claim_child_benefit`, obscuring registered +opt-outs. Those datasets must be rebuilt or migrated by their producer to +export registered claims, including opted-out claimants, before using this +model to score the return to payment. The model does not infer claims from +opt-out flags or dataset names, since that would revive EnhancedFRS +nonclaimants. Coordinate the corrected Microcosm export, this model and rebuilt +data in a release; updating model code alone does not repair existing data. + +`gov.hmrc.child_benefit.opt_out_charge_share` is a behavioural assumption, not +a statutory eligibility rule. It defaults to one: a flagged family continues +to opt out only while the charge would recover the full benefit. The charge +share is calculated independently of payment and clipped to [0, 1]. A reform +that reduces the share below the threshold, removes the charge or neutralises +`CB_HITC` restores payment. A zero charge never sustains an opt-out, even if +the assumed threshold is zero. Genuine nonclaimants remain outside payment. + +Microcosm selects fully charged families first and can draw from partly charged +families if needed to meet its target. The model's default threshold is an +explicit assumption; it does not reproduce that taper fallback. Researchers can +change the threshold to test other responses. A nonpositive taper width is +treated as a cliff: no charge at or below its start and a full charge above it. diff --git a/policyengine_uk/parameters/gov/hmrc/child_benefit/opt_out_charge_share.yaml b/policyengine_uk/parameters/gov/hmrc/child_benefit/opt_out_charge_share.yaml new file mode 100644 index 0000000000..ad239126fc --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/child_benefit/opt_out_charge_share.yaml @@ -0,0 +1,19 @@ +description: Assumed minimum share of Child Benefit recovered by the High Income + Child Benefit Charge at which a registered, opted-out family continues to forgo + payment. The default of one assumes opt-outs persist only while fully charged. + This is a behavioural modelling assumption, not a statutory eligibility rule. + Microcosm selects fully charged families first and can use the taper for any + remaining opt-out target; this threshold does not reproduce that fallback draw. +values: + 0001-01-01: 1 +metadata: + unit: /1 + period: year + label: Child Benefit opt-out charge share assumption + reference: + - title: Child Benefit opt-out modelling issue + href: https://github.com/PolicyEngine/policyengine-uk/issues/2065 + - title: Review recommendation for reform-responsive opt-outs + href: https://github.com/PolicyEngine/policyengine-uk/pull/2140#issuecomment-5992591745 + - title: Microcosm Child Benefit claims and opt-outs, full-charge-first selection + href: https://github.com/PolicyEngine/microcosm/blob/4163b819402d9417554e0cb4e9ff890973897c24/packages/microcosm-build/src/microcosm/build/uk_runtime/child_benefit_take_up.py#L629 diff --git a/policyengine_uk/parameters/gov/social_security_scotland/two_child_limit_payment/in_effect.yaml b/policyengine_uk/parameters/gov/social_security_scotland/two_child_limit_payment/in_effect.yaml index b77f79031b..195c4798ce 100644 --- a/policyengine_uk/parameters/gov/social_security_scotland/two_child_limit_payment/in_effect.yaml +++ b/policyengine_uk/parameters/gov/social_security_scotland/two_child_limit_payment/in_effect.yaml @@ -1,13 +1,12 @@ -description: Whether the Two Child Limit Payment is in effect in Scotland. +description: Whether the counterfactual Two Child Limit Payment is in effect in Scotland; the proposed regulations were not laid, so the baseline is inactive in every year. values: 0001-01-01: false - 2025-04-06: true metadata: unit: bool label: Two Child Limit Payment in effect period: year reference: - - title: Scottish Government - Two Child Limit Payment - href: https://www.gov.scot/policies/social-security/two-child-limit-payment/ - - title: Scottish Budget December 2024 announcement - href: https://www.gov.scot/publications/scottish-budget-2025-26/ + - title: Scottish Government confirmation that the regulations will not be laid, 3 December 2025 + href: https://socialsecuritycommission.scot/publications/draft-regulations/the-two-child-limit-payment-scotland-regulations-2026/the-two-child-limit-payment-scotland-regulations-2026-further-update-from-the-scottish-government/ + - title: Scottish Budget 2026-27, Chapter 5, Table 5.08 + href: https://www.gov.scot/publications/scottish-budget-2026-27/ diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index c4e9780bc4..5feee2bc38 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -211,6 +211,9 @@ def __init__( "employee_pension_contributions", "employee_pension_contributions_reported", ) + # Dataset totals must not mask journey-based fare policy formulas. + # The reported amount remains available for legacy data and Wales. + self.move_values("bus_fare_spending", "bus_fare_spending_reported") self.input_variables = self.get_known_variables() diff --git a/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml b/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml new file mode 100644 index 0000000000..52d193adeb --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dft/bus_journeys.yaml @@ -0,0 +1,128 @@ +# Hand arithmetic isolates the cap, ticket share and trip/boarding distinction. +# DfT national cap: England outside London, single tickets only. +- name: Case 1, a one-pound cap applies only to the single-fare share. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dft.bus.reference_year: 2025 + gov.dft.bus.reference_fares.cap: 2 + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: false + local_bus_single_fare_share: 0.5 + local_bus_uncapped_single_fare: 4 + gov.dft.bus.boardings_per_trip.ENGLAND_OUTSIDE_LONDON: 2 + gov.dft.bus.yield_per_boarding.ENGLAND_OUTSIDE_LONDON: 3 + gov.dft.bus.fares.cap: 1 + output: + local_bus_trips: 100 + # 100 * 2 * (3 + 0.5 * (min(4, 1) - min(4, 2))) = 500. + person_bus_fare_spending: 500 + bus_fare_spending: 500 + +- name: Case 2, pass holders pay nothing even when they make journeys. + period: 2025 + input: + gov.dft.bus.reference_year: 2025 + gov.dft.bus.reference_fares.cap: 2 + region: SOUTH_EAST + other_local_bus_trips: 100 + bus_pass_eligible: true + output: + person_bus_fare_spending: 0 + bus_fare_spending: 0 + +- name: Case 3, a national cap does not change London bus fares. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dft.bus.reference_year: 2025 + gov.dft.bus.reference_fares.cap: 2 + region: SOUTH_EAST + bus_in_london_trips: 100 + local_bus_single_fare_share: 1 + gov.dft.bus.boardings_per_trip.LONDON: 2 + gov.dft.bus.yield_per_boarding.LONDON: 3 + gov.dft.bus.fares.cap: 0 + output: + person_bus_fare_spending: 600 + +- name: Case 4, season-ticket journeys are unaffected by a single-fare cap. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dft.bus.reference_year: 2025 + gov.dft.bus.reference_fares.cap: 2 + region: SOUTH_EAST + local_bus_trips: 100 + local_bus_single_fare_share: 0 + gov.dft.bus.boardings_per_trip.ENGLAND_OUTSIDE_LONDON: 2 + gov.dft.bus.yield_per_boarding.ENGLAND_OUTSIDE_LONDON: 3 + gov.dft.bus.fares.cap: 0 + output: + person_bus_fare_spending: 600 + +- name: Case 5, members pay from their own journeys rather than their ages. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dft.bus.reference_year: 2025 + gov.dft.bus.reference_fares.cap: 2 + gov.dft.bus.boardings_per_trip.ENGLAND_OUTSIDE_LONDON: 1 + gov.dft.bus.yield_per_boarding.ENGLAND_OUTSIDE_LONDON: 2 + gov.dft.bus.fares.cap: 2 + people: + person1: {age: 30, other_local_bus_trips: 100} + person2: {age: 30, other_local_bus_trips: 20} + person3: {age: 70, other_local_bus_trips: 200, bus_pass_eligible: true} + households: + family: {members: [person1, person2, person3], region: SOUTH_EAST} + output: + person_bus_fare_spending: [200, 40, 0] + bus_fare_spending: 240 + +- name: Case 6, a cap above the uncapped fare does not raise it. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dft.bus.reference_year: 2025 + gov.dft.bus.reference_fares.cap: 2 + region: SOUTH_EAST + other_local_bus_trips: 100 + local_bus_single_fare_share: 1 + local_bus_uncapped_single_fare: 1.5 + gov.dft.bus.boardings_per_trip.ENGLAND_OUTSIDE_LONDON: 1 + gov.dft.bus.yield_per_boarding.ENGLAND_OUTSIDE_LONDON: 2 + gov.dft.bus.fares.cap: 3 + output: + person_bus_fare_spending: 200 + +- name: Case 7, a proportional fare reform also reaches non-single tickets. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dft.bus.reference_year: 2025 + gov.dft.bus.reference_fares.cap: 2 + region: SOUTH_EAST + other_local_bus_trips: 100 + local_bus_single_fare_share: 0 + gov.dft.bus.boardings_per_trip.ENGLAND_OUTSIDE_LONDON: 1 + gov.dft.bus.yield_per_boarding.ENGLAND_OUTSIDE_LONDON: 2 + gov.dft.bus.fare_index: 0.5 + output: + person_bus_fare_spending: 100 + +- name: Case 8, the national cap does not apply in Scotland. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dft.bus.reference_year: 2025 + gov.dft.bus.reference_fares.cap: 2 + region: SCOTLAND + other_local_bus_trips: 100 + local_bus_single_fare_share: 1 + gov.dft.bus.boardings_per_trip.SCOTLAND: 1 + gov.dft.bus.yield_per_boarding.SCOTLAND: 2 + gov.dft.bus.fares.cap: 0 + output: + person_bus_fare_spending: 200 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/opt_out.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/opt_out.yaml new file mode 100644 index 0000000000..248ef3aa10 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/opt_out.yaml @@ -0,0 +1,209 @@ +# HMRC: opting out stops payments, not registration or entitlement. +# https://www.gov.uk/child-benefit-tax-charge/stop-child-benefit +- name: Case 1, a charge-driven opt-out stops payment and the tax charge. + period: 2026 + input: + adjusted_net_income: 90_000 + child_benefit_entitlement: 1_000 + child_benefit_opts_out: true + would_claim_child_benefit: true + output: + child_benefit: 0 + CB_HITC: 0 + child_benefit_entitlement: 1_000 + +- name: Case 2, a claimant who has not opted out receives the benefit and pays the charge. + period: 2026 + input: + adjusted_net_income: 90_000 + child_benefit_entitlement: 1_000 + child_benefit_opts_out: false + output: + child_benefit: 1_000 + CB_HITC: 1_000 + +- name: Case 3, the default behavioural threshold restores payment below a full charge. + period: 2026 + input: + adjusted_net_income: 70_000 + child_benefit_entitlement: 1_000 + child_benefit_opts_out: true + output: + child_benefit: 1_000 + CB_HITC: 500 + +- name: Case 4, charge-driven opt-outs resume payment when a reform raises the threshold. + period: 2026 + input: + adjusted_net_income: 90_000 + child_benefit_entitlement: 1_000 + child_benefit_opts_out: true + gov.hmrc.income_tax.charges.CB_HITC.phase_out_start: 100_000 + gov.hmrc.income_tax.charges.CB_HITC.phase_out_end: 120_000 + output: + child_benefit: 1_000 + CB_HITC: 0 + +- name: Case 5, a family exactly at the charge threshold has no charge-driven opt-out. + period: 2026 + input: + adjusted_net_income: 60_000 + child_benefit_entitlement: 1_000 + child_benefit_opts_out: true + output: + child_benefit: 1_000 + CB_HITC: 0 + +- name: Case 6, removing the charge does not make a non-claimant claim. + period: 2026 + input: + adjusted_net_income: 40_000 + child_benefit_entitlement: 1_000 + # The independent claim flag determines nonclaimant status. + child_benefit_opts_out: false + would_claim_child_benefit: false + output: + child_benefit: 0 + +- name: Case 7, an opted-out couple is tested against the higher earner. + period: 2026 + input: + people: + person1: {age: 35, adjusted_net_income: 20_000} + person2: {age: 36, adjusted_net_income: 90_000} + benunits: + family: + members: [person1, person2] + child_benefit_entitlement: 1_000 + child_benefit_opts_out: true + output: + child_benefit: 0 + CB_HITC: [0, 0] + +# The opt-out charge share is a behavioural assumption, not a statutory rule. +# https://github.com/PolicyEngine/policyengine-uk/pull/2140#issuecomment-5992591745 +- name: Case 8, a finite softer taper restores payment despite a positive charge. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: {age: 40, adjusted_net_income: 100_000} + person2: {age: 8} + person3: {age: 5} + benunits: + family: + members: [person1, person2, person3] + child_benefit_opts_out: true + gov.hmrc.income_tax.charges.CB_HITC.phase_out_end: 10_000_000 + output: + child_benefit_entitlement: 2_337.40 + child_benefit: 2_337.40 + # (100,000 - 60,000) / (10,000,000 - 60,000) * 2,337.40. + CB_HITC: [9.406036, 0, 0] + +- name: Case 9, the configurable opt-out threshold includes its exact boundary. + period: 2026 + absolute_error_margin: 0.01 + input: + adjusted_net_income: [69_999, 70_000, 70_001] + child_benefit_entitlement: [1_000, 1_000, 1_000] + child_benefit_opts_out: [true, true, true] + gov.hmrc.child_benefit.opt_out_charge_share: 0.5 + output: + child_benefit: [1_000, 0, 0] + CB_HITC: [499.95, 0, 0] + +- name: Case 10, a zero behavioural threshold still requires a positive charge. + period: 2026 + input: + adjusted_net_income: [59_999, 60_000, 60_001] + child_benefit_entitlement: [1_000, 1_000, 1_000] + child_benefit_opts_out: [true, true, true] + gov.hmrc.child_benefit.opt_out_charge_share: 0 + output: + child_benefit: [1_000, 1_000, 0] + CB_HITC: [0, 0, 0] + +- name: Case 11, a zero-width taper applies as a cliff strictly above the start. + period: 2026 + input: + adjusted_net_income: [59_999, 60_000, 60_001] + child_benefit_entitlement: [1_000, 1_000, 1_000] + child_benefit_opts_out: [true, true, true] + gov.hmrc.income_tax.charges.CB_HITC.phase_out_end: 60_000 + output: + child_benefit: [1_000, 1_000, 0] + CB_HITC: [0, 0, 0] + +- name: Case 12, an unflagged family receives payment and pays the cliff charge. + period: 2026 + input: + adjusted_net_income: [59_999, 60_000, 60_001] + child_benefit_entitlement: [1_000, 1_000, 1_000] + child_benefit_opts_out: [false, false, false] + gov.hmrc.income_tax.charges.CB_HITC.phase_out_end: 60_000 + output: + child_benefit: [1_000, 1_000, 1_000] + CB_HITC: [0, 0, 1_000] + +- name: Case 13, a reversed taper also uses a cliff at the start. + period: 2026 + input: + adjusted_net_income: [59_999, 60_000, 60_001] + child_benefit_entitlement: [1_000, 1_000, 1_000] + child_benefit_opts_out: [true, true, true] + gov.hmrc.income_tax.charges.CB_HITC.phase_out_end: 50_000 + output: + child_benefit: [1_000, 1_000, 0] + CB_HITC: [0, 0, 0] + +- name: Case 14, a registered opted-out claim resumes when the charge is removed. + period: 2026 + input: + adjusted_net_income: 90_000 + child_benefit_entitlement: 1_000 + would_claim_child_benefit: true + child_benefit_opts_out: true + gov.hmrc.income_tax.charges.CB_HITC.phase_out_end: .inf + output: + child_benefit: 1_000 + CB_HITC: 0 + +# EnhancedFRS draws the opt-out flag independently of its claim flag. +# https://github.com/PolicyEngine/policyengine-uk/pull/2140#issuecomment-5993831707 +- name: Case 15, an independent opt-out draw does not register a nonclaimant. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: {age: 40, adjusted_net_income: 30_000} + person2: {age: 8} + person3: {age: 5} + benunits: + family: + members: [person1, person2, person3] + would_claim_child_benefit: false + child_benefit_opts_out: true + output: + child_benefit_entitlement: 2_337.40 + child_benefit: 0 + CB_HITC: [0, 0, 0] + +- name: Case 16, charge removal does not revive an independent nonclaimant opt-out draw. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: {age: 40, adjusted_net_income: 30_000} + person2: {age: 8} + person3: {age: 5} + benunits: + family: + members: [person1, person2, person3] + would_claim_child_benefit: false + child_benefit_opts_out: true + gov.hmrc.income_tax.charges.CB_HITC.phase_out_end: .inf + output: + child_benefit_entitlement: 2_337.40 + child_benefit: 0 + CB_HITC: [0, 0, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/two_child_limit_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/two_child_limit_payment.yaml index 205ccf6291..588e53ef29 100644 --- a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/two_child_limit_payment.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/two_child_limit_payment.yaml @@ -1,5 +1,5 @@ # Two Child Limit Payment tests -# Payment is active from April 2025 onwards (fiscal year 2025-26) +# The proposed regulations were never laid. Positive cases enable a counterfactual. - name: No payment in Scotland before April 2025 period: 2024 @@ -28,10 +28,11 @@ # Payment not in effect before April 2025 two_child_limit_payment: 0 -- name: Payment in Scotland in 2025 +- name: Counterfactual - Payment in Scotland in 2025 period: 2025 - absolute_error_margin: 1 + absolute_error_margin: 0.01 input: + gov.social_security_scotland.two_child_limit_payment.in_effect: true people: parent: age: 30 @@ -53,12 +54,13 @@ region: SCOTLAND output: # UC child element amount * 12 months * 1 affected child - two_child_limit_payment: 3514 + two_child_limit_payment: 3513.72 -- name: Payment for multiple affected children in Scotland +- name: Counterfactual - Payment for multiple affected children in Scotland period: 2025 - absolute_error_margin: 1 + absolute_error_margin: 0.01 input: + gov.social_security_scotland.two_child_limit_payment.in_effect: true people: parent: age: 30 @@ -83,12 +85,13 @@ region: SCOTLAND output: # UC child element amount * 12 months * 2 affected children - two_child_limit_payment: 7027 + two_child_limit_payment: 7027.44 -- name: Large family with three affected children in Scotland +- name: Counterfactual - Large family with three affected children in Scotland period: 2025 - absolute_error_margin: 1 + absolute_error_margin: 0.01 input: + gov.social_security_scotland.two_child_limit_payment.in_effect: true people: parent: age: 35 @@ -116,12 +119,13 @@ region: SCOTLAND output: # UC child element amount * 12 months * 3 affected children - two_child_limit_payment: 10541 + two_child_limit_payment: 10541.16 - name: No payment if no children affected in Scotland period: 2025 absolute_error_margin: 1 input: + gov.social_security_scotland.two_child_limit_payment.in_effect: true people: parent: age: 30 @@ -145,6 +149,7 @@ period: 2025 absolute_error_margin: 1 input: + gov.social_security_scotland.two_child_limit_payment.in_effect: true people: parent: age: 30 @@ -171,6 +176,7 @@ period: 2025 absolute_error_margin: 1 input: + gov.social_security_scotland.two_child_limit_payment.in_effect: true people: parent: age: 30 @@ -197,6 +203,7 @@ period: 2025 absolute_error_margin: 1 input: + gov.social_security_scotland.two_child_limit_payment.in_effect: true people: parent: age: 30 @@ -219,10 +226,11 @@ output: two_child_limit_payment: 0 -- name: Two parent household in Scotland +- name: Counterfactual - Two parent household in Scotland period: 2025 - absolute_error_margin: 1 + absolute_error_margin: 0.01 input: + gov.social_security_scotland.two_child_limit_payment.in_effect: true people: parent_1: age: 35 @@ -245,12 +253,13 @@ members: [parent_1, parent_2, child_1, child_2, child_3] region: SCOTLAND output: - two_child_limit_payment: 3514 + two_child_limit_payment: 3513.72 -- name: Payment continues in Scotland in 2026 +- name: Counterfactual - Payment continues in Scotland in 2026 period: 2026 - absolute_error_margin: 1 + absolute_error_margin: 0.01 input: + gov.social_security_scotland.two_child_limit_payment.in_effect: true people: parent: age: 30 @@ -271,5 +280,59 @@ members: [parent, child_1, child_2, child_3] region: SCOTLAND output: - # Payment continues as two-child limit still in effect + # Counterfactual amount uses the 2026 UC child element two_child_limit_payment: 3647.28 + +- name: No baseline payment in Scotland in 2025 + period: 2025 + absolute_error_margin: 1 + input: + people: + parent: + age: 30 + child_1: + age: 5 + uc_is_child_limit_affected: false + child_2: + age: 3 + uc_is_child_limit_affected: false + child_3: + age: 1 + uc_is_child_limit_affected: true + benunits: + benunit: + members: [parent, child_1, child_2, child_3] + households: + household: + members: [parent, child_1, child_2, child_3] + region: SCOTLAND + output: + # No regulations introduced the payment + two_child_limit_payment: 0 + +- name: No baseline payment in Scotland in 2026 + period: 2026 + absolute_error_margin: 1 + input: + people: + parent: + age: 30 + child_1: + age: 5 + uc_is_child_limit_affected: false + child_2: + age: 3 + uc_is_child_limit_affected: false + child_3: + age: 1 + uc_is_child_limit_affected: true + benunits: + benunit: + members: [parent, child_1, child_2, child_3] + households: + household: + members: [parent, child_1, child_2, child_3] + region: SCOTLAND + output: + # No regulations introduced the payment + two_child_limit_payment: 0 diff --git a/policyengine_uk/tests/policy/baseline/household/is_renting.yaml b/policyengine_uk/tests/policy/baseline/household/is_renting.yaml new file mode 100644 index 0000000000..946a7b9780 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/is_renting.yaml @@ -0,0 +1,8 @@ +- name: Case 1, all three rental tenures and both owner tenures agree at each entity level. + period: 2026 + input: + tenure_type: [RENT_FROM_COUNCIL, RENT_FROM_HA, RENT_PRIVATELY, OWNED_OUTRIGHT, OWNED_WITH_MORTGAGE] + benunit_tenure_type: [RENT_FROM_COUNCIL, RENT_FROM_HA, RENT_PRIVATELY, OWNED_OUTRIGHT, OWNED_WITH_MORTGAGE] + output: + is_renting: [true, true, true, false, false] + benunit_is_renting: [true, true, true, false, false] diff --git a/policyengine_uk/tests/test_bus_fare_reforms.py b/policyengine_uk/tests/test_bus_fare_reforms.py new file mode 100644 index 0000000000..e1614e9c38 --- /dev/null +++ b/policyengine_uk/tests/test_bus_fare_reforms.py @@ -0,0 +1,151 @@ +"""Exercise data loading, nonlinear calendar dates and real Scenario reforms.""" + +import numpy as np +import pandas as pd +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.data.dataset_schema import UKMultiYearDataset, UKSingleYearDataset +from policyengine_uk.system import system +from policyengine_uk.utils.bus_fares import annual_capped_fare +from policyengine_uk.utils.scenario import Scenario + + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + +def bus_dataset(region="SOUTH_EAST", journeys=True, zero=False): + person = pd.DataFrame( + { + "person_id": [1, 2], + "person_benunit_id": [1, 1], + "person_household_id": [1, 1], + "age": [30, 30], + } + ) + if journeys: + person["other_local_bus_trips"] = [0, 0] if zero else [100, 20] + person["bus_in_london_trips"] = [0, 0] + person["bus_pass_eligible"] = [False, False] + person["local_bus_single_fare_share"] = [0.7, 0.7] + return UKMultiYearDataset( + datasets=[ + UKSingleYearDataset( + person=person, + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame( + { + "household_id": [1], + "region": [region], + "bus_fare_spending": [999.0], + } + ), + fiscal_year=2024, + ) + ] + ) + + +def test_loaded_total_does_not_mask_journeys_or_cap_reforms(): + simulation = Simulation( + dataset=bus_dataset(), + scenario=Scenario(parameter_changes={"gov.dft.bus.fares.cap": {2024: np.inf}}), + ) + # Published 2024-25 receipts / fare-paying boardings, translated from trips. + k = 1_850_509_887.26131 / (28.0685543170643 * 58_620_101) + fare_paying = 1_850_509_887.26131 - 519_107_608.59444 + benchmark = 2_069_953_713.42079 / fare_paying + uncapped_yield = benchmark + 515_693_216.6 / fare_paying + baseline_people = np.array([100, 20]) * k * benchmark + reformed_people = np.array([100, 20]) * k * uncapped_yield + assert simulation.baseline.calculate( + "person_bus_fare_spending", 2024 + ) == pytest.approx(baseline_people) + assert simulation.calculate("person_bus_fare_spending", 2024) == pytest.approx( + reformed_people + ) + assert simulation.calculate("bus_fare_spending", 2024)[0] == pytest.approx( + reformed_people.sum() + ) + assert simulation.baseline.calculate("bus_fare_spending", 2024)[0] == pytest.approx( + baseline_people.sum() + ) + assert simulation.calculate("bus_fare_spending_reported", 2024)[0] == 999 + + +def test_explicit_zero_journeys_replace_a_stale_positive_total(): + simulation = Simulation(dataset=bus_dataset(zero=True)) + assert simulation.calculate("bus_fare_spending", 2024)[0] == 0 + + +@pytest.mark.parametrize("region", ["SOUTH_EAST", "WALES", "UNKNOWN"]) +def test_legacy_fallback_and_proportional_reform(region): + simulation = Simulation( + dataset=bus_dataset(region, journeys=False), + scenario=Scenario(parameter_changes={"gov.dft.bus.fare_index": {2024: 0.5}}), + ) + # Calculating a default trip count must not make it a supplied zero. + assert simulation.calculate("local_bus_trips", 2024).sum() == 0 + assert simulation.baseline.calculate("bus_fare_spending", 2024)[0] == 999 + assert simulation.calculate("bus_fare_spending", 2024)[0] == 499.5 + assert simulation.calculate("person_bus_fare_spending", 2024) == pytest.approx( + [249.75, 249.75] + ) + + +def test_wales_keeps_reported_spending_even_with_journeys(): + simulation = Simulation(dataset=bus_dataset("WALES")) + assert simulation.calculate("bus_fare_spending", 2024)[0] == 999 + + +@pytest.mark.parametrize("region", ["SCOTLAND", "NORTHERN_IRELAND"]) +def test_national_cap_reform_does_not_change_devolved_fares(region): + simulation = Simulation( + dataset=bus_dataset(region), + scenario=Scenario(parameter_changes={"gov.dft.bus.fares.cap": {2024: 0}}), + ) + assert simulation.calculate("bus_fare_spending", 2024)[0] > 0 + assert simulation.calculate("bus_fare_spending", 2024) == pytest.approx( + simulation.baseline.calculate("bus_fare_spending", 2024) + ) + + +def test_ridership_multiplier_is_explicit_and_does_not_change_input_journeys(): + simulation = Simulation( + dataset=bus_dataset(), + scenario=Scenario( + parameter_changes={"gov.dft.bus.ridership_index": {2024: 1.2}} + ), + ) + assert simulation.calculate("bus_fare_spending", 2024)[0] == pytest.approx( + simulation.baseline.calculate("bus_fare_spending", 2024)[0] * 1.2 + ) + assert simulation.calculate("local_bus_trips", 2024).tolist() == [100, 20] + + +def test_caps_apply_before_annual_averaging_and_preserve_january_changes(): + schedule = system.parameters.gov.dft.bus.fares + assert schedule("2024-12-31").cap == 2 + assert schedule("2025-01-01").cap == 3 + assert schedule("2026-12-31").cap == 3 + assert schedule("2027-01-01").cap == 2 + assert np.isinf(schedule("2028-01-01").cap) + # A £2.50 fare is capped at £2 for 270 days, then costs £2.50 for 95. + assert annual_capped_fare(2.5, schedule, 2024) == pytest.approx( + (270 * 2 + 95 * 2.5) / 365 + ) + # The £2 cap returns part-way through the 2026 fiscal year. + assert annual_capped_fare(4, schedule, 2026) == pytest.approx( + (270 * 3 + 95 * 2) / 365 + ) + + +def test_clone_does_not_lose_journey_input_provenance(): + original = Simulation(dataset=bus_dataset()) + expected = original.calculate("bus_fare_spending", 2024)[0] + clone = original.clone() + clone.delete_arrays("bus_fare_spending") + clone.delete_arrays("person_bus_fare_spending") + assert clone.calculate("bus_fare_spending", 2024)[0] == expected + clone.delete_arrays("other_local_bus_trips") + assert original.calculate("other_local_bus_trips", 2024).tolist() == [100, 20] diff --git a/policyengine_uk/tests/test_child_benefit_opt_out.py b/policyengine_uk/tests/test_child_benefit_opt_out.py new file mode 100644 index 0000000000..012ef7ceca --- /dev/null +++ b/policyengine_uk/tests/test_child_benefit_opt_out.py @@ -0,0 +1,175 @@ +import numpy as np +import pandas as pd +import pytest +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.data.dataset_schema import UKMultiYearDataset, UKSingleYearDataset + + +class abolish_child_benefit_charge(Reform): + def apply(self): + self.neutralize_variable("CB_HITC") + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +@pytest.mark.parametrize( + "reform", + [ + abolish_child_benefit_charge, + { + "gov.hmrc.income_tax.charges.CB_HITC.phase_out_start": {"2026": 100_000}, + "gov.hmrc.income_tax.charges.CB_HITC.phase_out_end": {"2026": 120_000}, + }, + {"gov.hmrc.income_tax.charges.CB_HITC.phase_out_end": {"2026": float("inf")}}, + ], +) +def test_removing_charge_restores_payment_without_changing_baseline(reform): + situation = { + "people": { + "parent": {"age": {2026: 40}, "adjusted_net_income": {2026: 90_000}}, + "child": {"age": {2026: 5}}, + }, + "benunits": { + "family": { + "members": ["parent", "child"], + "child_benefit_opts_out": {2026: True}, + } + }, + "households": {"home": {"members": ["parent", "child"]}}, + } + simulation = Simulation(situation=situation, reform=reform) + entitlement = simulation.calculate("child_benefit_entitlement", 2026)[0] + assert entitlement > 0 + assert simulation.baseline.calculate("child_benefit", 2026)[0] == 0 + assert simulation.calculate("child_benefit", 2026)[0] == entitlement + assert simulation.calculate("CB_HITC", 2026).sum() == 0 + assert simulation.baseline.calculate("child_benefit", 2026)[0] == 0 + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +@pytest.mark.parametrize("income", [59_999, 60_000, 60_001]) +@pytest.mark.parametrize("opts_out", [False, True]) +@pytest.mark.parametrize("taper_end", [50_000, 60_000]) +def test_cliff_charge_has_no_division_warnings(income, opts_out, taper_end): + """Degenerate taper reforms charge fully only above their start threshold.""" + simulation = Simulation( + situation={ + "people": { + "parent": {"age": {2026: 40}, "adjusted_net_income": {2026: income}}, + "child": {"age": {2026: 5}}, + }, + "benunits": { + "family": { + "members": ["parent", "child"], + "child_benefit_opts_out": {2026: opts_out}, + } + }, + "households": {"home": {"members": ["parent", "child"]}}, + }, + reform={ + "gov.hmrc.income_tax.charges.CB_HITC.phase_out_end": {"2026": taper_end} + }, + ) + with np.errstate(divide="raise", invalid="raise"): + payment = simulation.calculate("child_benefit", 2026)[0] + charge = simulation.calculate("CB_HITC", 2026).sum() + entitlement = simulation.calculate("child_benefit_entitlement", 2026)[0] + assert payment == (0 if opts_out and income > 60_000 else entitlement) + assert charge == (payment if income > 60_000 else 0) + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +@pytest.mark.parametrize("nonclaimant_income", [30_000, 100_000]) +@pytest.mark.parametrize( + "reform, payment, charge", + [ + (None, 0, 0), + (abolish_child_benefit_charge, 2_337.40, 0), + ( + { + "gov.hmrc.income_tax.charges.CB_HITC.phase_out_start": { + "2026": 200_000 + }, + "gov.hmrc.income_tax.charges.CB_HITC.phase_out_end": {"2026": 220_000}, + }, + 2_337.40, + 0, + ), + ( + { + "gov.hmrc.income_tax.charges.CB_HITC.phase_out_end": { + "2026": float("inf") + } + }, + 2_337.40, + 0, + ), + ( + {"gov.hmrc.income_tax.charges.CB_HITC.phase_out_end": {"2026": 10_000_000}}, + 2_337.40, + 2_337.40 * 40_000 / 9_940_000, + ), + ], +) +def test_dataset_opted_out_claimants_resume_but_nonclaimants_do_not( + reform, payment, charge, nonclaimant_income +): + """Claim status stays independent of opt-out draws, including in EnhancedFRS.""" + dataset = UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1, 2, 3, 4, 5, 6, 7, 8, 9], + "person_benunit_id": [1, 1, 1, 2, 2, 2, 3, 3, 3], + "person_household_id": [1, 1, 1, 2, 2, 2, 3, 3, 3], + "age": [40, 8, 5, 40, 8, 5, 40, 8, 5], + "adjusted_net_income": [ + 100_000, + 0, + 0, + 100_000, + 0, + 0, + nonclaimant_income, + 0, + 0, + ], + } + ), + benunit=pd.DataFrame( + { + "benunit_id": [1, 2, 3], + # Registered opt-out, nonclaimant, independent EnhancedFRS-style draw. + "would_claim_child_benefit": [True, False, False], + "child_benefit_opts_out": [True, False, True], + } + ), + household=pd.DataFrame( + { + "household_id": [1, 2, 3], + "region": ["SOUTH_EAST", "SOUTH_EAST", "SOUTH_EAST"], + } + ), + fiscal_year=2026, + ) + simulation = Simulation( + dataset=UKMultiYearDataset(datasets=[dataset]), reform=reform + ) + assert simulation.calculate("child_benefit_entitlement", 2026) == pytest.approx( + [2_337.40, 2_337.40, 2_337.40], abs=0.01 + ) + assert simulation.baseline.calculate("child_benefit", 2026) == pytest.approx( + [0, 0, 0] + ) + assert simulation.baseline.calculate("CB_HITC", 2026) == pytest.approx( + [0, 0, 0, 0, 0, 0, 0, 0, 0] + ) + assert simulation.calculate("child_benefit", 2026) == pytest.approx( + [payment, 0, 0], abs=0.01 + ) + assert simulation.calculate("CB_HITC", 2026) == pytest.approx( + [charge, 0, 0, 0, 0, 0, 0, 0, 0], abs=0.01 + ) + assert simulation.baseline.calculate("child_benefit", 2026) == pytest.approx( + [0, 0, 0] + ) diff --git a/policyengine_uk/utils/bus_fares.py b/policyengine_uk/utils/bus_fares.py new file mode 100644 index 0000000000..f52a6cfc7e --- /dev/null +++ b/policyengine_uk/utils/bus_fares.py @@ -0,0 +1,21 @@ +"""Identify supplied bus journeys and annualise nonlinear single-fare caps.""" + +from policyengine_uk.utils.excise import fiscal_year_segments +from policyengine_uk.utils.supplied_inputs import supplied_input +import numpy as np + + +def has_bus_journeys(person, period): + """An explicitly supplied zero is journey data; a cached default is not.""" + return any( + supplied_input(person, name, period) is not None + for name in ("bus_in_london_trips", "other_local_bus_trips", "local_bus_trips") + ) + + +def annual_capped_fare(uncapped, schedule, year): + """Cap each fare before day-weighting over 6 April to 5 April.""" + return sum( + np.minimum(uncapped, max(rates.cap, 0)) * share + for rates, share in fiscal_year_segments(schedule, year) + ) diff --git a/policyengine_uk/utils/child_benefit.py b/policyengine_uk/utils/child_benefit.py new file mode 100644 index 0000000000..e8d1092c38 --- /dev/null +++ b/policyengine_uk/utils/child_benefit.py @@ -0,0 +1,16 @@ +import numpy as np + + +def child_benefit_charge_share( + income: np.ndarray, phase_out_start: float, phase_out_end: float +) -> np.ndarray: + """Bound the charge share, treating nonpositive taper widths as a cliff.""" + income = np.asarray(income) + width = phase_out_end - phase_out_start + share = np.divide( + np.maximum(income - phase_out_start, 0), + width, + out=np.asarray(income > phase_out_start, dtype=float), + where=width > 0, + ) + return np.clip(share, 0, 1) diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index f28a1a5163..3e7d34caf7 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -26,6 +26,10 @@ # Variables that formulas ask about through the helpers below. SUPPLIED_INPUT_VARIABLES = frozenset( { + "bus_fare_spending", + "bus_in_london_trips", + "other_local_bus_trips", + "local_bus_trips", "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", "trading_loss", diff --git a/policyengine_uk/utils/tenure.py b/policyengine_uk/utils/tenure.py new file mode 100644 index 0000000000..08fcb6f9d7 --- /dev/null +++ b/policyengine_uk/utils/tenure.py @@ -0,0 +1,10 @@ +"""Tenure categories shared by household and benefit-unit rental flags.""" + + +def is_renting_tenure(tenure): + tenures = tenure.possible_values + return ( + (tenure == tenures.RENT_PRIVATELY) + | (tenure == tenures.RENT_FROM_COUNCIL) + | (tenure == tenures.RENT_FROM_HA) + ) diff --git a/policyengine_uk/variables/gov/dft/bus_fare_spending_reported.py b/policyengine_uk/variables/gov/dft/bus_fare_spending_reported.py new file mode 100644 index 0000000000..19c6f72512 --- /dev/null +++ b/policyengine_uk/variables/gov/dft/bus_fare_spending_reported.py @@ -0,0 +1,12 @@ +from policyengine_uk.model_api import * + + +class bus_fare_spending_reported(Variable): + label = "bus fare spending reported" + documentation = "Reported household bus and coach spending, retained as the fallback when usable journey data is absent." + entity = Household + definition_period = YEAR + value_type = float + quantity_type = FLOW + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.consumer_price_index" diff --git a/policyengine_uk/variables/gov/dft/bus_in_london_trips.py b/policyengine_uk/variables/gov/dft/bus_in_london_trips.py new file mode 100644 index 0000000000..7aef128f46 --- /dev/null +++ b/policyengine_uk/variables/gov/dft/bus_in_london_trips.py @@ -0,0 +1,14 @@ +from policyengine_uk.model_api import * + + +class bus_in_london_trips(Variable): + label = "bus in london trips" + documentation = ( + "Annual local-bus trips on the bus-in-London series, supplied by the dataset. " + "Supply bus_pass_eligible alongside journeys: the model does not infer " + "statutory pass eligibility and a missing flag defaults to paying fares." + ) + entity = Person + definition_period = YEAR + value_type = float + quantity_type = FLOW diff --git a/policyengine_uk/variables/gov/dft/bus_pass_eligible.py b/policyengine_uk/variables/gov/dft/bus_pass_eligible.py new file mode 100644 index 0000000000..ce156fffdb --- /dev/null +++ b/policyengine_uk/variables/gov/dft/bus_pass_eligible.py @@ -0,0 +1,9 @@ +from policyengine_uk.model_api import * + + +class bus_pass_eligible(Variable): + label = "bus pass eligible" + documentation = "Whether the person travels free under the concessionary rule supplied by the dataset; this input is not a pass-eligibility calculator." + entity = Person + definition_period = YEAR + value_type = bool diff --git a/policyengine_uk/variables/gov/dft/local_bus_single_fare_share.py b/policyengine_uk/variables/gov/dft/local_bus_single_fare_share.py new file mode 100644 index 0000000000..f478eef7b5 --- /dev/null +++ b/policyengine_uk/variables/gov/dft/local_bus_single_fare_share.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class local_bus_single_fare_share(Variable): + label = "local bus single fare share" + documentation = "Share of fare-paying boardings bought as single tickets. Dataset input; the fallback is the 2024 NTS other-local-bus mean." + entity = Person + definition_period = YEAR + value_type = float + unit = "/1" + + def formula(person, period, parameters): + return parameters(period).gov.dft.bus.default_single_fare_share diff --git a/policyengine_uk/variables/gov/dft/local_bus_trips.py b/policyengine_uk/variables/gov/dft/local_bus_trips.py new file mode 100644 index 0000000000..5f91614182 --- /dev/null +++ b/policyengine_uk/variables/gov/dft/local_bus_trips.py @@ -0,0 +1,16 @@ +from policyengine_uk.model_api import * + + +class local_bus_trips(Variable): + label = "annual local bus trips" + documentation = ( + "Total trips across the two local-bus series; may also be supplied directly " + "when the series split is unavailable. Supply bus_pass_eligible alongside " + "journeys: the model does not infer statutory pass eligibility and a " + "missing flag defaults to paying fares." + ) + entity = Person + definition_period = YEAR + value_type = float + quantity_type = FLOW + adds = ["bus_in_london_trips", "other_local_bus_trips"] diff --git a/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py b/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py new file mode 100644 index 0000000000..36a43e8967 --- /dev/null +++ b/policyengine_uk/variables/gov/dft/local_bus_uncapped_single_fare.py @@ -0,0 +1,13 @@ +from policyengine_uk.model_api import * + + +class local_bus_uncapped_single_fare(Variable): + label = "local bus uncapped single fare" + documentation = "Representative uncapped single fare for other-local-bus journeys, in pounds. Override to model a known fare; the default is an aggregate no-demand-response counterfactual." + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + + def formula(person, period, parameters): + return parameters(period).gov.dft.bus.uncapped_single_fare diff --git a/policyengine_uk/variables/gov/dft/other_local_bus_trips.py b/policyengine_uk/variables/gov/dft/other_local_bus_trips.py new file mode 100644 index 0000000000..a90a7c235f --- /dev/null +++ b/policyengine_uk/variables/gov/dft/other_local_bus_trips.py @@ -0,0 +1,14 @@ +from policyengine_uk.model_api import * + + +class other_local_bus_trips(Variable): + label = "other local bus trips" + documentation = ( + "Annual local-bus trips on the other-local-bus series, supplied by the dataset. " + "Supply bus_pass_eligible alongside journeys: the model does not infer " + "statutory pass eligibility and a missing flag defaults to paying fares." + ) + entity = Person + definition_period = YEAR + value_type = float + quantity_type = FLOW diff --git a/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py b/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py index f3ae5fbebd..208e33512b 100644 --- a/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py +++ b/policyengine_uk/variables/gov/dft/person_bus_fare_spending.py @@ -1,14 +1,15 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.bus_fares import annual_capped_fare, has_bus_journeys +from policyengine_uk.utils.supplied_inputs import supplied_input class person_bus_fare_spending(Variable): label = "personal bus and coach fare spending" documentation = ( - "Share of the household's bus and coach fare spending attributed to " - "this person. The LCFS records fares at household level only, so the " - "split uses a National Travel Survey age profile of local bus use, " - "adjusted for concessionary travel. Members' values sum to the " - "household's bus_fare_spending." + "Fare-paying bus journeys priced at the published yield, with changes " + "in the single-fare cap applied only to single-ticket boardings in " + "England outside London. Without usable journey data, allocates " + "reported household spending by age. See docs/engineering/bus-fares.md." ) entity = Person definition_period = YEAR @@ -17,19 +18,81 @@ class person_bus_fare_spending(Variable): quantity_type = FLOW def formula(person, period, parameters): - weight = parameters(period).gov.dft.bus.fare_allocation_weight_by_age.calc( - person("age", period) - ) + p = parameters(period).gov.dft.bus + if p.prior_law_fare_index <= 0: + raise ValueError("The reference bus fare index must be positive") + fare_multiplier = max_(p.fare_index, 0) / p.prior_law_fare_index + weight = p.fare_allocation_weight_by_age.calc(person("age", period)) household_weight = person.household("household_bus_fare_age_weight", period) - share = where(household_weight > 0, weight / household_weight, 0) - return person.household("bus_fare_spending", period) * share + share = weight / where(household_weight > 0, household_weight, 1) + # The YAML runner can receive an explicit household total directly. + # UK Simulation moves such totals to the reported input on loading. + reported = supplied_input(person.household, "bus_fare_spending", period) + if reported is None: + reported = person.household("bus_fare_spending_reported", period) + else: + reported = person.household.project(reported) + fallback = reported * share * fare_multiplier + if period.start.year < 2024 or not has_bus_journeys(person, period): + return fallback + + region = person.household("region", period) + regions = region.possible_values + in_scotland = region == regions.SCOTLAND + in_ni = region == regions.NORTHERN_IRELAND + unpriced = (region == regions.WALES) | (region == regions.UNKNOWN) + in_england = ~(in_scotland | in_ni | unpriced) + london_trips = person("bus_in_london_trips", period) + other_trips = person("other_local_bus_trips", period) + if all( + supplied_input(person, name, period) is None + for name in ("bus_in_london_trips", "other_local_bus_trips") + ): + total = person("local_bus_trips", period) + london_trips = where(region == regions.LONDON, total, 0) + other_trips = where(region == regions.LONDON, 0, total) + other_boardings = select( + [in_scotland, in_ni], + [p.boardings_per_trip.SCOTLAND, p.boardings_per_trip.NORTHERN_IRELAND], + default=p.boardings_per_trip.ENGLAND_OUTSIDE_LONDON, + ) + other_yield = select( + [in_scotland, in_ni], + [p.yield_per_boarding.SCOTLAND, p.yield_per_boarding.NORTHERN_IRELAND], + default=p.yield_per_boarding.ENGLAND_OUTSIDE_LONDON, + ) + uncapped = max_(person("local_bus_uncapped_single_fare", period), 0) + current_single = annual_capped_fare( + uncapped, parameters.gov.dft.bus.fares, period.start.year + ) + reference_single = annual_capped_fare( + uncapped, parameters.gov.dft.bus.reference_fares, int(p.reference_year) + ) + single_share = clip(person("local_bus_single_fare_share", period), 0, 1) + other_yield = max_( + other_yield + + in_england * single_share * (current_single - reference_single), + 0, + ) + spending = ( + max_(london_trips, 0) + * p.boardings_per_trip.LONDON + * p.yield_per_boarding.LONDON + + max_(other_trips, 0) * other_boardings * other_yield + ) + spending *= ( + ~person("bus_pass_eligible", period) + * fare_multiplier + * max_(p.ridership_index, 0) + ) + return where(unpriced, fallback, spending) class household_bus_fare_age_weight(Variable): label = "household bus fare age weight" documentation = ( - "Sum of members' bus fare allocation weights, the denominator that " - "makes person_bus_fare_spending sum to household bus_fare_spending." + "Sum of members' bus fare allocation weights, used to distribute " + "reported household spending when journey pricing is unavailable." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/gov/hmrc/child_benefit.py b/policyengine_uk/variables/gov/hmrc/child_benefit.py index 4b4c471d65..9366ed059e 100644 --- a/policyengine_uk/variables/gov/hmrc/child_benefit.py +++ b/policyengine_uk/variables/gov/hmrc/child_benefit.py @@ -1,13 +1,35 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.child_benefit import child_benefit_charge_share class child_benefit(Variable): label = "Child Benefit" - documentation = "Total Child Benefit for the benefit unit" + documentation = ( + "Total Child Benefit paid to a benefit unit that would claim it. " + "Claimants with payment opt-outs resume when the charge share falls below " + "the assumed opt-out threshold; an opt-out flag never establishes a claim." + ) entity = BenUnit definition_period = YEAR value_type = float unit = GBP category = BENEFIT defined_for = "would_claim_child_benefit" - adds = ["child_benefit_entitlement"] + reference = "https://www.gov.uk/child-benefit-tax-charge/stop-child-benefit" + + def formula(benunit, period, parameters): + entitlement = benunit("child_benefit_entitlement", period) + opts_out = benunit("child_benefit_opts_out", period) + hitc = parameters(period).gov.hmrc.income_tax.charges.CB_HITC + income = benunit.max(benunit.members("adjusted_net_income", period)) + # Compute the fraction independently of payment to avoid the cycle + # child_benefit -> CB_HITC -> child_benefit. + charge_fraction = child_benefit_charge_share( + income, hitc.phase_out_start, hitc.phase_out_end + ) + opt_out_share = parameters(period).gov.hmrc.child_benefit.opt_out_charge_share + remains_opted_out = (charge_fraction > 0) & (charge_fraction >= opt_out_share) + charge = benunit.simulation.tax_benefit_system.get_variable("CB_HITC") + if charge.is_neutralized: + remains_opted_out = False + return entitlement * ~(opts_out & remains_opted_out) diff --git a/policyengine_uk/variables/gov/hmrc/child_benefit_opts_out.py b/policyengine_uk/variables/gov/hmrc/child_benefit_opts_out.py index 3de1c91c1d..758358a59c 100644 --- a/policyengine_uk/variables/gov/hmrc/child_benefit_opts_out.py +++ b/policyengine_uk/variables/gov/hmrc/child_benefit_opts_out.py @@ -4,9 +4,13 @@ class child_benefit_opts_out(Variable): label = "opts out of Child Benefit" documentation = ( - "Whether this family would opt out of receiving Child Benefit " - "payments. Generated stochastically in the dataset using opt-out " - "rates." + "Whether a family would opt out of Child Benefit payments because of the " + "High Income Child Benefit Charge. This is independent of " + "would_claim_child_benefit and never identifies a claim by itself. " + "For claimants, payments resume when the simulated charge share falls below " + "the assumed " + "opt_out_charge_share threshold (one by default), or the charge is zero " + "or neutralised. Entitlement and nonclaimant status are unchanged." ) entity = BenUnit definition_period = YEAR diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/charges/child_benefit_hitc.py b/policyengine_uk/variables/gov/hmrc/income_tax/charges/child_benefit_hitc.py index ab4ea09a55..b5add0442d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/charges/child_benefit_hitc.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/charges/child_benefit_hitc.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.child_benefit import child_benefit_charge_share class CB_HITC(Variable): @@ -14,7 +15,7 @@ def formula(person, period, parameters): CB_received = person.benunit("child_benefit", period) hitc = parameters(period).gov.hmrc.income_tax.charges.CB_HITC income = person("adjusted_net_income", period) - percentage = max_(income - hitc.phase_out_start, 0) / ( - hitc.phase_out_end - hitc.phase_out_start + percentage = child_benefit_charge_share( + income, hitc.phase_out_start, hitc.phase_out_end ) - return min_(percentage, 1) * CB_received + return percentage * CB_received diff --git a/policyengine_uk/variables/gov/hmrc/would_claim_child_benefit.py b/policyengine_uk/variables/gov/hmrc/would_claim_child_benefit.py index 9a84f521ad..5bff0df410 100644 --- a/policyengine_uk/variables/gov/hmrc/would_claim_child_benefit.py +++ b/policyengine_uk/variables/gov/hmrc/would_claim_child_benefit.py @@ -4,8 +4,11 @@ class would_claim_child_benefit(Variable): label = "Would claim Child Benefit" documentation = ( - "Whether this benefit unit would claim Child Benefit if eligible. " - "Generated stochastically in the dataset using take-up rates." + "Whether this benefit unit would claim Child Benefit if eligible, " + "including registered claimants who opt out of payment. This claim flag " + "is independent of child_benefit_opts_out; false always means no payment. " + "Datasets must export claims rather than claims excluding opt-outs. " + "Household calculators default to claiming unless supplied otherwise." ) entity = BenUnit definition_period = YEAR diff --git a/policyengine_uk/variables/gov/social_security_scotland/two_child_limit_payment.py b/policyengine_uk/variables/gov/social_security_scotland/two_child_limit_payment.py index 6488d995c0..1b716df2a8 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/two_child_limit_payment.py +++ b/policyengine_uk/variables/gov/social_security_scotland/two_child_limit_payment.py @@ -4,8 +4,10 @@ class two_child_limit_payment(Variable): label = "Two Child Limit Payment" documentation = ( - "Scotland's payment to mitigate the UK two-child benefit cap. " - "Equals the UC child element for each child affected by the limit." + "Counterfactual Scottish mitigation of the UK two-child benefit cap. " + "The proposed regulations were not laid, so the baseline pays zero. " + "When enabled by a reform, pays the UC child element for each child " + "affected by the limit." ) entity = BenUnit definition_period = YEAR @@ -19,7 +21,7 @@ class two_child_limit_payment(Variable): def formula(benunit, period, parameters): in_scotland = benunit.household("country", period).decode_to_str() == "SCOTLAND" - # Check if payment is in effect (from December 2024) + # A reform can enable the proposed scheme. in_effect = parameters( period ).gov.social_security_scotland.two_child_limit_payment.in_effect diff --git a/policyengine_uk/variables/household/demographic/benunit_is_renting.py b/policyengine_uk/variables/household/demographic/benunit_is_renting.py index 5d29ac2847..d615412483 100644 --- a/policyengine_uk/variables/household/demographic/benunit_is_renting.py +++ b/policyengine_uk/variables/household/demographic/benunit_is_renting.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.tenure import is_renting_tenure class benunit_is_renting(Variable): @@ -8,11 +9,4 @@ class benunit_is_renting(Variable): definition_period = YEAR def formula(benunit, period, parameters): - tenure = benunit("benunit_tenure_type", period) - tenures = tenure.possible_values - RENT_TENURES = [ - tenures.RENT_PRIVATELY, - tenures.RENT_FROM_COUNCIL, - tenures.RENT_FROM_HA, - ] - return sum([tenure == tenure_type for tenure_type in RENT_TENURES]) > 0 + return is_renting_tenure(benunit("benunit_tenure_type", period)) diff --git a/policyengine_uk/variables/household/demographic/is_renting.py b/policyengine_uk/variables/household/demographic/is_renting.py index 150f134c63..f3280deda2 100644 --- a/policyengine_uk/variables/household/demographic/is_renting.py +++ b/policyengine_uk/variables/household/demographic/is_renting.py @@ -1,5 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.variables.household.demographic.geography import Region +from policyengine_uk.utils.tenure import is_renting_tenure class is_renting(Variable): @@ -9,11 +9,4 @@ class is_renting(Variable): definition_period = YEAR def formula(household, period, parameters): - tenure = household("tenure_type", period) - tenures = tenure.possible_values - RENT_TENURES = [ - tenures.RENT_PRIVATELY, - tenures.RENT_FROM_COUNCIL, - tenures.RENT_PRIVATELY, - ] - return is_in(tenure, RENT_TENURES) + return is_renting_tenure(household("tenure_type", period)) diff --git a/policyengine_uk/variables/input/consumption/bus_fare_spending.py b/policyengine_uk/variables/input/consumption/bus_fare_spending.py index 2d80c1b9e8..58b3ecf849 100644 --- a/policyengine_uk/variables/input/consumption/bus_fare_spending.py +++ b/policyengine_uk/variables/input/consumption/bus_fare_spending.py @@ -8,10 +8,15 @@ class bus_fare_spending(Variable): label = "bus and coach fare spending" - documentation = "Household spending on bus and coach fares (COICOP 7.3.2)." + documentation = ( + "Household bus fares summed from each person's journeys where supplied. " + "Without journey data, retains reported bus and coach spending " + "(COICOP 7.3.2), allocated across people by age. Already included in " + "transport_consumption; do not add it to consumption again." + ) entity = Household definition_period = YEAR value_type = float unit = GBP quantity_type = FLOW - uprating = "gov.economic_assumptions.indices.obr.consumer_price_index" + adds = ["person_bus_fare_spending"]