From c9f01a96df2e8ce25664e15bd6b03d526ced0586 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:50:10 +0400 Subject: [PATCH 1/3] Fix TV licence liability trigger --- changelog.d/2145.fixed.md | 1 + .../bbc/tv-licence/free_tv_licence_value.yaml | 19 ++++++++++++ .../gov/dcms/bbc/tv-licence/tv_licence.yaml | 29 +++++++++++++++++++ .../bbc/tv_licence/free_tv_licence_value.py | 8 +++-- .../gov/dcms/bbc/tv_licence/tv_licence.py | 9 ++++-- .../bbc/tv_licence/tv_licence_required.py | 20 +++++++++++++ 6 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 changelog.d/2145.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml create mode 100644 policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py diff --git a/changelog.d/2145.fixed.md b/changelog.d/2145.fixed.md new file mode 100644 index 0000000000..d92d4d0229 --- /dev/null +++ b/changelog.d/2145.fixed.md @@ -0,0 +1 @@ +- Based TV licence charges on licensable viewing rather than television ownership alone. diff --git a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml new file mode 100644 index 0000000000..8566b086ea --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml @@ -0,0 +1,19 @@ +- name: Free licence value applies to BBC iPlayer use without television ownership. + period: 2026 + input: + household_owns_tv: false + tv_licence_required: true + tv_licence_discount: 1 + would_evade_tv_licence_fee: false + output: + free_tv_licence_value: 180 + +- name: Television ownership alone creates no free licence value. + period: 2026 + input: + household_owns_tv: true + tv_licence_required: false + tv_licence_discount: 1 + would_evade_tv_licence_fee: false + output: + free_tv_licence_value: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml index 4b62332a7c..44c2f0b536 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml @@ -42,3 +42,32 @@ would_evade_tv_licence_fee: false output: tv_licence: 0 + +- name: TV licence required for BBC iPlayer without television ownership. + period: 2026 + input: + household_owns_tv: false + tv_licence_required: true + tv_licence_discount: 0 + would_evade_tv_licence_fee: false + output: + tv_licence: 180 + +- name: Television ownership alone does not require a TV licence. + period: 2026 + input: + household_owns_tv: true + tv_licence_required: false + tv_licence_discount: 0 + would_evade_tv_licence_fee: false + output: + tv_licence: 0 + +- name: Television ownership remains the fallback for existing inputs. + period: 2026 + input: + household_owns_tv: false + tv_licence_discount: 0 + would_evade_tv_licence_fee: false + output: + tv_licence: 0 diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py index 6626fc9842..576d0255ec 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py @@ -8,13 +8,15 @@ class free_tv_licence_value(Variable): value_type = float unit = "/1" reference = ( + "https://www.legislation.gov.uk/ukpga/2003/21/section/363", "https://www.legislation.gov.uk/ukpga/2003/21/section/365A", - "https://www.tvlicensing.co.uk/reducedfee", + "https://www.legislation.gov.uk/uksi/2016/704/regulation/9/made", + "https://www.gov.uk/free-discount-tv-licence", ) def formula(household, period, parameters): - owns_tv = household("household_owns_tv", period) + licence_required = household("tv_licence_required", period) discount = household("tv_licence_discount", period) would_evade = household("would_evade_tv_licence_fee", period) fee = parameters(period).gov.dcms.bbc.tv_licence.colour - return (owns_tv & ~would_evade) * fee * discount + return (licence_required & ~would_evade) * fee * discount diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence.py index ef0fb57f54..ad6c0eb9e7 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence.py @@ -8,12 +8,15 @@ class tv_licence(Variable): definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/ukpga/2003/21/section/363" + reference = ( + "https://www.legislation.gov.uk/ukpga/2003/21/section/363", + "https://www.legislation.gov.uk/uksi/2016/704/regulation/9/made", + ) category = TAX def formula(household, period, parameters): - owns_tv = household("household_owns_tv", period) + licence_required = household("tv_licence_required", period) discount = household("tv_licence_discount", period) would_evade = household("would_evade_tv_licence_fee", period) fee = parameters(period).gov.dcms.bbc.tv_licence.colour - return (owns_tv & ~would_evade) * fee * (1 - discount) + return (licence_required & ~would_evade) * fee * (1 - discount) diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py new file mode 100644 index 0000000000..da64969f6a --- /dev/null +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class tv_licence_required(Variable): + label = "TV licence required" + documentation = ( + "Whether anyone in the household watches or records live television " + "or uses BBC iPlayer. When this is not provided directly, television " + "ownership is used as a backward-compatible proxy." + ) + entity = Household + definition_period = YEAR + value_type = bool + reference = ( + "https://www.legislation.gov.uk/ukpga/2003/21/section/363", + "https://www.legislation.gov.uk/uksi/2016/704/regulation/9/made", + ) + + def formula(household, period, parameters): + return household("household_owns_tv", period) From 3791ca2e319bdaa9e0158e0018cd2500052554ec Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:20:03 +0400 Subject: [PATCH 2/3] Correct free TV licence HBAI value --- changelog.d/2145.fixed.md | 2 +- .../bbc/tv-licence/free_tv_licence_value.yaml | 22 +++++++++++++++++-- .../tests/test_tv_licence_metadata.py | 8 +++++++ .../bbc/tv_licence/free_tv_licence_value.py | 7 ++++-- 4 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 policyengine_uk/tests/test_tv_licence_metadata.py diff --git a/changelog.d/2145.fixed.md b/changelog.d/2145.fixed.md index d92d4d0229..8552054381 100644 --- a/changelog.d/2145.fixed.md +++ b/changelog.d/2145.fixed.md @@ -1 +1 @@ -- Based TV licence charges on licensable viewing rather than television ownership alone. +- Based TV licence charges on licensable viewing and corrected the HBAI free-licence value. diff --git a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml index 8566b086ea..486c8774f6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml @@ -1,4 +1,4 @@ -- name: Free licence value applies to BBC iPlayer use without television ownership. +- name: Case 1, BBC iPlayer use without television ownership. period: 2026 input: household_owns_tv: false @@ -8,7 +8,7 @@ output: free_tv_licence_value: 180 -- name: Television ownership alone creates no free licence value. +- name: Case 2, television ownership without licensable use. period: 2026 input: household_owns_tv: true @@ -17,3 +17,21 @@ would_evade_tv_licence_fee: false output: free_tv_licence_value: 0 + +- name: Case 3, full free licence value enters HBAI income in kind. + period: 2026 + input: + tv_licence_required: true + tv_licence_discount: 1 + would_evade_tv_licence_fee: false + output: + free_tv_licence_value: 180 + +- name: Case 4, blind discount is not a free licence in HBAI income. + period: 2026 + input: + tv_licence_required: true + tv_licence_discount: 0.5 + would_evade_tv_licence_fee: false + output: + free_tv_licence_value: 0 diff --git a/policyengine_uk/tests/test_tv_licence_metadata.py b/policyengine_uk/tests/test_tv_licence_metadata.py new file mode 100644 index 0000000000..6c90645b81 --- /dev/null +++ b/policyengine_uk/tests/test_tv_licence_metadata.py @@ -0,0 +1,8 @@ +from policyengine_uk import CountryTaxBenefitSystem +from policyengine_uk.model_api import GBP + + +def test_free_tv_licence_value_uses_currency_units(): + system = CountryTaxBenefitSystem() + + assert system.variables["free_tv_licence_value"].unit == GBP diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py index 576d0255ec..cf7760399b 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py @@ -6,12 +6,14 @@ class free_tv_licence_value(Variable): entity = Household definition_period = YEAR value_type = float - unit = "/1" + unit = GBP reference = ( "https://www.legislation.gov.uk/ukpga/2003/21/section/363", "https://www.legislation.gov.uk/ukpga/2003/21/section/365A", "https://www.legislation.gov.uk/uksi/2016/704/regulation/9/made", + "https://www.legislation.gov.uk/uksi/2026/104/made", "https://www.gov.uk/free-discount-tv-licence", + "https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025", ) def formula(household, period, parameters): @@ -19,4 +21,5 @@ def formula(household, period, parameters): discount = household("tv_licence_discount", period) would_evade = household("would_evade_tv_licence_fee", period) fee = parameters(period).gov.dcms.bbc.tv_licence.colour - return (licence_required & ~would_evade) * fee * discount + receives_free_licence = discount == 1 + return (licence_required & ~would_evade & receives_free_licence) * fee From a99deaa5f787a16175296db0814a7b35e4068c76 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:04:50 +0400 Subject: [PATCH 3/3] Fix issues from review: split free TV licence share --- .../bbc/tv-licence/free_tv_licence_value.yaml | 70 ++++++++++++++++++- .../gov/dcms/bbc/tv-licence/tv_licence.yaml | 12 ++++ .../bbc/tv_licence/free_tv_licence_value.py | 12 +++- .../bbc/tv_licence/tv_licence_discount.py | 21 ++---- .../tv_licence_free_licence_share.py | 34 +++++++++ .../bbc/tv_licence/tv_licence_required.py | 4 +- .../tv_licence/would_evade_tv_licence_fee.py | 3 +- 7 files changed, 132 insertions(+), 24 deletions(-) create mode 100644 policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_free_licence_share.py diff --git a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml index 486c8774f6..f1dbfc9544 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml @@ -3,7 +3,7 @@ input: household_owns_tv: false tv_licence_required: true - tv_licence_discount: 1 + tv_licence_free_licence_share: 1 would_evade_tv_licence_fee: false output: free_tv_licence_value: 180 @@ -13,7 +13,7 @@ input: household_owns_tv: true tv_licence_required: false - tv_licence_discount: 1 + tv_licence_free_licence_share: 1 would_evade_tv_licence_fee: false output: free_tv_licence_value: 0 @@ -22,7 +22,7 @@ period: 2026 input: tv_licence_required: true - tv_licence_discount: 1 + tv_licence_free_licence_share: 1 would_evade_tv_licence_fee: false output: free_tv_licence_value: 180 @@ -31,7 +31,71 @@ period: 2026 input: tv_licence_required: true + tv_licence_free_licence_share: 0 + is_blind: true + would_evade_tv_licence_fee: false + output: tv_licence_discount: 0.5 + free_tv_licence_value: 0 + +- name: Case 5, a partial-year free licence contributes its proportional value. + period: 2020 + absolute_error_margin: 0.01 + input: + tv_licence_required: true + tv_licence_free_licence_share: 0.3205479452 would_evade_tv_licence_fee: false output: + free_tv_licence_value: 50.49 + +- name: Case 6, an over-75 household on Pension Credit gets a full free licence. + period: 2026 + input: + age: 80 + pension_credit: 1 + tv_licence_required: true + would_evade_tv_licence_fee: false + output: + tv_licence_free_licence_share: 1 + free_tv_licence_value: 180 + +- name: Case 7, age alone does not qualify after the Pension Credit condition begins. + period: 2026 + input: + age: 80 + pension_credit: 0 + tv_licence_required: true + would_evade_tv_licence_fee: false + output: + tv_licence_free_licence_share: 0 free_tv_licence_value: 0 + +- name: Case 8, the 2020 transition determines both free value and paid liability. + period: 2020 + absolute_error_margin: 0.01 + input: + age: 80 + pension_credit: 0 + tv_licence_required: true + is_blind: false + would_evade_tv_licence_fee: false + output: + tv_licence_free_licence_share: 0.3205479452 + tv_licence_discount: 0.3205479452 + free_tv_licence_value: 50.49 + tv_licence: 107.01 + +- name: Case 9, the blind discount reduces only the paid part of the 2020 licence. + period: 2020 + absolute_error_margin: 0.01 + input: + age: 80 + pension_credit: 0 + tv_licence_required: true + is_blind: true + would_evade_tv_licence_fee: false + output: + tv_licence_free_licence_share: 0.3205479452 + tv_licence_discount: 0.6602739726 + free_tv_licence_value: 50.49 + tv_licence: 53.51 diff --git a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml index 44c2f0b536..7a33fb64d0 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml @@ -71,3 +71,15 @@ would_evade_tv_licence_fee: false output: tv_licence: 0 + +- name: Blind discount applies only after a partial-year free licence ends. + period: 2020 + absolute_error_margin: 0.01 + input: + tv_licence_required: true + tv_licence_free_licence_share: 0.3205479452 + is_blind: true + would_evade_tv_licence_fee: false + output: + tv_licence_discount: 0.6602739726 + tv_licence: 53.51 diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py index cf7760399b..9e725b692d 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py @@ -3,6 +3,10 @@ class free_tv_licence_value(Variable): label = "free TV licence value" + documentation = ( + "Value included in HBAI income for the share of the year covered by " + "the complete age-based exemption. The blind discount is excluded." + ) entity = Household definition_period = YEAR value_type = float @@ -18,8 +22,10 @@ class free_tv_licence_value(Variable): def formula(household, period, parameters): licence_required = household("tv_licence_required", period) - discount = household("tv_licence_discount", period) + free_licence_share = household( + "tv_licence_free_licence_share", + period, + ) would_evade = household("would_evade_tv_licence_fee", period) fee = parameters(period).gov.dcms.bbc.tv_licence.colour - receives_free_licence = discount == 1 - return (licence_required & ~would_evade & receives_free_licence) * fee + return (licence_required & ~would_evade) * fee * free_licence_share diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_discount.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_discount.py index 655b1eca18..c220d08179 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_discount.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_discount.py @@ -1,5 +1,4 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.excise import fiscal_year_segments class tv_licence_discount(Variable): @@ -17,24 +16,14 @@ class tv_licence_discount(Variable): def formula(household, period, parameters): person = household.members tv_licence = parameters(period).gov.dcms.bbc.tv_licence - - # Aged discount - claims_pc = add(household, period, ["pension_credit"]) > 0 - aged_discount = 0 - for aged_rules, share in fiscal_year_segments( - parameters.gov.dcms.bbc.tv_licence.discount.aged, - period.start.year, - ): - aged = person("age", period) >= aged_rules.min_age - has_aged = household.any(aged) - meets_pc_requirement = not_(aged_rules.must_claim_pc) | claims_pc - aged_discount += ( - has_aged * meets_pc_requirement * aged_rules.discount * share - ) + free_licence_share = household( + "tv_licence_free_licence_share", + period, + ) # Blind discount is_blind = person("is_blind", period) has_blind = household.any(is_blind) blind_discount = has_blind * tv_licence.discount.blind.discount - return max_(aged_discount, blind_discount) + return free_licence_share + (1 - free_licence_share) * blind_discount diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_free_licence_share.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_free_licence_share.py new file mode 100644 index 0000000000..5be64444cc --- /dev/null +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_free_licence_share.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.excise import fiscal_year_segments + + +class tv_licence_free_licence_share(Variable): + label = "Share of the year covered by a free TV licence" + documentation = ( + "Share of the year for which the household qualifies for the complete " + "age-based TV licence exemption. This excludes the blind discount." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = "/1" + reference = ( + "https://www.legislation.gov.uk/ukpga/2003/21/section/365A", + "https://www.gov.uk/government/news/bbc-and-governments-joint-statement-on-delaying-the-tv-licence-fee-for-over-75s", + "https://www.gov.uk/free-discount-tv-licence", + ) + + def formula(household, period, parameters): + person = household.members + claims_pc = add(household, period, ["pension_credit"]) > 0 + free_licence_share = 0 + for aged_rules, share in fiscal_year_segments( + parameters.gov.dcms.bbc.tv_licence.discount.aged, + period.start.year, + ): + has_aged = household.any(person("age", period) >= aged_rules.min_age) + meets_pc_requirement = not_(aged_rules.must_claim_pc) | claims_pc + free_licence_share += ( + has_aged * meets_pc_requirement * aged_rules.discount * share + ) + return free_licence_share diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py index da64969f6a..f7acf05b1d 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py @@ -6,7 +6,9 @@ class tv_licence_required(Variable): documentation = ( "Whether anyone in the household watches or records live television " "or uses BBC iPlayer. When this is not provided directly, television " - "ownership is used as a backward-compatible proxy." + "ownership is used as a backward-compatible proxy. That proxy can " + "overstate liability for owners who use only non-BBC on-demand services " + "and understate it for non-owners who watch live television or BBC iPlayer." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/would_evade_tv_licence_fee.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/would_evade_tv_licence_fee.py index 61be081395..7239d88489 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/would_evade_tv_licence_fee.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/would_evade_tv_licence_fee.py @@ -5,7 +5,8 @@ class would_evade_tv_licence_fee(Variable): label = "Would evade TV licence fee" documentation = ( "Whether this household would unlawfully evade the TV licence fee. " - "Generated stochastically in the dataset using evasion rates." + "Generated stochastically in the dataset using evasion rates. The " + "realised result does not change in response to reforms of the fee." ) entity = Household definition_period = YEAR