diff --git a/.gitignore b/.gitignore index 922e4958c0..dbc4c49e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ policyengine_uk/calibration/*.h5 *.ipynb !docs/**/*.ipynb !uprating_growth_factors.csv +!policyengine_uk/parameters/gov/dwp/LHA/lha_published_rates_sources.csv # Virtual environments .venv/ diff --git a/changelog.d/2012.fixed.md b/changelog.d/2012.fixed.md new file mode 100644 index 0000000000..c5415b719e --- /dev/null +++ b/changelog.d/2012.fixed.md @@ -0,0 +1 @@ +- Local Housing Allowance rates now come from the published determinations instead of the 30th percentile of the 2020 list of rents uprated by the private rent index. Sources: VOA, the Welsh and Scottish Governments, the NIHE, and monthly Universal Credit rates from DWP (Great Britain) and nidirect (Northern Ireland). Determinations from April 2020 are recomputed from the published 30th percentile rents with the national maxima, the anomalous-rate rule (Rent Officers Order Sch 3B para 3) and the March 2020 minimum (para 3A, which also applies to Northern Ireland's Universal Credit rates). This reproduces every published rate and stops rates falling with dwelling size. Universal Credit uses the monthly determination rather than 52 weeks of the weekly one. diff --git a/changelog.d/2036.fixed.md b/changelog.d/2036.fixed.md new file mode 100644 index 0000000000..e50abb5746 --- /dev/null +++ b/changelog.d/2036.fixed.md @@ -0,0 +1 @@ +- Replaced the Scottish lists of rents, which were copies of English BRMAs' lists, with Rent Service Scotland's own lists released under FOI 202200303624, so percentile reforms scale Scottish LHA rates by Scotland's rent distributions. diff --git a/docs/book/programs/gov/dwp/housing-benefit.ipynb b/docs/book/programs/gov/dwp/housing-benefit.ipynb index 86ba080317..2e1ddc4271 100644 --- a/docs/book/programs/gov/dwp/housing-benefit.ipynb +++ b/docs/book/programs/gov/dwp/housing-benefit.ipynb @@ -47,6 +47,30 @@ "The take-up gate (`would_claim_housing_benefit`) is applied inside `housing_benefit_pre_benefit_cap`." ] }, + { + "cell_type": "markdown", + "id": "lha0rates0published0determinations", + "metadata": {}, + "source": [ + "## Local Housing Allowance rates\n", + "\n", + "A private renter's eligible rent is capped at the Local Housing Allowance (LHA) for their Broad Rental Market Area (BRMA) and LHA category: shared accommodation, or one to four bedrooms (`LHA_category`). Rent officers determine the rates each January for the year from April. In England this is the Valuation Office Agency, in Wales Rent Officers Wales, in Scotland Rent Service Scotland, and in Northern Ireland the Northern Ireland Housing Executive.\n", + "\n", + "PolicyEngine reads the published determinations from `parameters/gov/dwp/LHA/lha_published_rates.csv.gz`. `scripts/build_lha_published_rates.py` builds that table from the source files listed, with their hashes, in `lha_published_rates_sources.csv`. For each determination from April 2020, the rate is recomputed from the published 30th percentile rents, following [Schedule 3B](https://www.legislation.gov.uk/uksi/1997/1984/schedule/3B) to the Rent Officers (Housing Benefit Functions) Order 1997:\n", + "\n", + "1. the lower of the percentile rent and the national maximum (`gov.dwp.LHA.maximum`), paragraph 2(2), rounded to the nearest penny (paragraph 2(10));\n", + "2. raised to the rate of any smaller category, paragraph 3;\n", + "3. for determinations from April 2024, no lower than the rate determined on 31 March 2020 (`gov.dwp.LHA.march_2020_minimum`), paragraph 3A.\n", + "\n", + "While `gov.dwp.LHA.freeze` is true, the rates of the last unfrozen year apply (the Modification Orders for 2021 to 2023, 2025 and 2026). A year is the fiscal year from April, so 2025 means April 2025 to March 2026. These rules reproduce every published rate since April 2020. Where a publisher gives no separate percentile for a reset year (Wales and Northern Ireland in April 2020, Northern Ireland in April 2024), the published rate stands in for it, so for those cells the match shows only that the rules leave an unadjusted rate alone. Universal Credit has its own monthly determination ([Schedule 1](https://www.legislation.gov.uk/uksi/2013/382/schedule/1) to the Universal Credit Functions Order 2013), with monthly maxima (`gov.dwp.LHA.maximum_monthly`). Its monthly tables come from DWP for Great Britain and from nidirect for Northern Ireland from April 2024; Northern Ireland's earlier monthly rates were not published, so the model converts the weekly percentile (365/7 weeks a year over 12 months). Rent Officers Wales's April 2022 and 2023 tables restate 14 of the weekly April 2020 rates they say they hold, and DWP's monthly tables restate the same 14 plus three more by a penny or two. Each restated figure is a round rent (16 of the 17 monthly figures are multiples of £5; the other, Vale of Glamorgan one-bedroom at £434.52, is £100 a week), which reads as a correction, so the model takes them as published: a held year adjusts the held determination's percentile rent (so a reform to the maximum still binds), and the March 2020 minimum uses the restated figures. Determinations before April 2020 are used as published, because the model does not encode the rules of that period: CPI and 1% uprating, then the 2016 to 2020 freeze with targeted affordability uplifts.\n", + "\n", + "Reforms work as follows:\n", + "\n", + "- Lifting a freeze uses the percentile rents published for that year where they exist. The VOA, Rent Officers Wales and the Scottish Government publish them even for frozen years. The NIHE has not since 2018, so Northern Ireland, and the few Welsh cells missing from a table, use the latest earlier percentile grown with `gov.indices.private_rent_index`.\n", + "- Beyond the latest table, the latest percentile rent grows with `gov.indices.private_rent_index`.\n", + "- A percentile other than the 30th (`gov.dwp.LHA.percentile`) scales the published one by the ratio of the two percentiles in the model's list of rents for the same BRMA and category. The English lists are the VOA's and the Scottish lists are Rent Service Scotland's own. The Welsh and Northern Irish lists in the file are copies of English ones, so those BRMAs take the median English ratio for the category.\n" + ] + }, { "cell_type": "markdown", "id": "8dd0d8092fe74a7c96281538738b07e2", diff --git a/policyengine_uk/parameters/gov/dwp/LHA/README.md b/policyengine_uk/parameters/gov/dwp/LHA/README.md index 7a4e35a5e1..ea44202deb 100644 --- a/policyengine_uk/parameters/gov/dwp/LHA/README.md +++ b/policyengine_uk/parameters/gov/dwp/LHA/README.md @@ -1 +1,9 @@ # Local Housing Allowance + +## Lists of rents + +`lha_list_of_rents.csv.gz` holds one row per rent in the lists behind the April 2019 and April 2020 determinations (`year`), with its BRMA and LHA category. The model uses it only to turn the published 30th percentile into another percentile when a reform changes `percentile`. + +- **England:** the Valuation Office Agency's published lists. +- **Scotland:** Rent Service Scotland's records, released by the Scottish Government under FOI 202200303624. The list for April Y is the sheet for the year to September Y-1. `scripts/build_scottish_list_of_rents.py` rebuilds these rows from the pinned workbook. In 522 of 540 cells (April 2017-2022), those sheets reproduce the published 30th percentiles to the penny. +- **Wales and Northern Ireland:** copies of English BRMAs' lists, each matched on the nation's April 2019 rate (issue #2036). Their percentile reforms use the median English ratio for the category instead. diff --git a/policyengine_uk/parameters/gov/dwp/LHA/__init__.py b/policyengine_uk/parameters/gov/dwp/LHA/__init__.py index 3d0d3e4ebc..0d9423078e 100644 --- a/policyengine_uk/parameters/gov/dwp/LHA/__init__.py +++ b/policyengine_uk/parameters/gov/dwp/LHA/__init__.py @@ -1,4 +1,10 @@ import pandas as pd from pathlib import Path -lha_list_of_rents = pd.read_csv(Path(__file__).parent / "lha_list_of_rents.csv.gz") + +def __getattr__(name): + # The list of rents is large, and only percentile reforms need it, so it + # is read on first access rather than when the package is imported. + if name == "lha_list_of_rents": + return pd.read_csv(Path(__file__).parent / "lha_list_of_rents.csv.gz") + raise AttributeError(name) diff --git a/policyengine_uk/parameters/gov/dwp/LHA/freeze.yaml b/policyengine_uk/parameters/gov/dwp/LHA/freeze.yaml index 514b3c2842..774aeeb681 100644 --- a/policyengine_uk/parameters/gov/dwp/LHA/freeze.yaml +++ b/policyengine_uk/parameters/gov/dwp/LHA/freeze.yaml @@ -1,5 +1,5 @@ description: >- - While this parameter is true, LHA rates are frozen in cash terms at the level set in the most recent year in which it was false. When this parameter is false, values are set at the (default 30th) BRMA percentile of that year's rents. Values from 2027 onward reflect announced rather than enacted policy: no Modification Order has been made beyond 2026-27, and the Autumn Statement 2023 costing maintains the April 2024 uplift in cash terms thereafter. + While this parameter is true, LHA rates are frozen in cash terms at the level determined in the most recent year in which it was false. When it is false, rates are determined from that year's percentile rents (the 30th percentile by default), the national maximum, the anomalous-rate rule and, from April 2024, the March 2020 minimum. A year here is the fiscal year from April, so the value from 6 April 2025 governs the rates in force from April 2025. Determinations before April 2020 are taken as published, since they followed CPI and 1% uprating and then the 2016 to 2020 freeze with targeted affordability uplifts, so this parameter governs determinations from April 2020 onward. Values from 2027 onward reflect announced rather than enacted policy: no Modification Order has been made beyond 2026-27, and the Autumn Statement 2023 costing maintains the April 2024 uplift in cash terms thereafter. values: 2015-01-01: false 2020-04-06: @@ -8,6 +8,8 @@ values: reference: - title: The Social Security (Coronavirus) (Further Measures) Regulations 2020, reg. 4 (April 2020 reset to the 30th percentile) href: https://www.legislation.gov.uk/uksi/2020/371/regulation/4/made + - title: The Social Security (Coronavirus) (Further Measures) Regulations (Northern Ireland) 2020, reg. 4 + href: https://www.legislation.gov.uk/nisr/2020/53/regulation/4/made 2021-04-06: value: true metadata: @@ -18,12 +20,20 @@ values: href: https://www.legislation.gov.uk/uksi/2021/1380/made - title: The Rent Officers (Housing Benefit and Universal Credit Functions) (Modification) Order 2023 (determinations in 2023) href: https://www.legislation.gov.uk/uksi/2023/6/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2021 + href: https://www.legislation.gov.uk/nisr/2021/14/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Amendment and Modification) Regulations (Northern Ireland) 2022 + href: https://www.legislation.gov.uk/nisr/2022/15/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2023 + href: https://www.legislation.gov.uk/nisr/2023/4/made 2024-04-06: value: false metadata: reference: - title: The Rent Officers (Housing Benefit and Universal Credit Functions) (Amendment) Order 2024 (April 2024 reset to the 30th percentile) href: https://www.legislation.gov.uk/uksi/2024/11/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Amendment) Regulations (Northern Ireland) 2024 + href: https://www.legislation.gov.uk/nisr/2024/3/made 2025-04-06: value: true metadata: @@ -32,6 +42,10 @@ values: href: https://www.legislation.gov.uk/uksi/2025/5/made - title: The Rent Officers (Housing Benefit and Universal Credit Functions) (Modification) Order 2026 href: https://www.legislation.gov.uk/uksi/2026/5/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2025 + href: https://www.legislation.gov.uk/nisr/2025/1/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Modification) Regulations (Northern Ireland) 2026 + href: https://www.legislation.gov.uk/nisr/2026/2/made - title: Autumn Statement 2023 policy costings (April 2024 uplift maintained in cash terms thereafter) href: https://assets.publishing.service.gov.uk/media/655d0a83544aea000dfb321d/Autumn_Statement_2023_Policy_Costings_-_Final.pdf#page=16 metadata: diff --git a/policyengine_uk/parameters/gov/dwp/LHA/lha_list_of_rents.csv.gz b/policyengine_uk/parameters/gov/dwp/LHA/lha_list_of_rents.csv.gz index af02bfda5c..ac38727ae0 100644 Binary files a/policyengine_uk/parameters/gov/dwp/LHA/lha_list_of_rents.csv.gz and b/policyengine_uk/parameters/gov/dwp/LHA/lha_list_of_rents.csv.gz differ diff --git a/policyengine_uk/parameters/gov/dwp/LHA/lha_published_rates.csv.gz b/policyengine_uk/parameters/gov/dwp/LHA/lha_published_rates.csv.gz new file mode 100644 index 0000000000..70d922a855 Binary files /dev/null and b/policyengine_uk/parameters/gov/dwp/LHA/lha_published_rates.csv.gz differ diff --git a/policyengine_uk/parameters/gov/dwp/LHA/lha_published_rates_sources.csv b/policyengine_uk/parameters/gov/dwp/LHA/lha_published_rates_sources.csv new file mode 100644 index 0000000000..2d84fd0a47 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/LHA/lha_published_rates_sources.csv @@ -0,0 +1,82 @@ +id,nation,year,parser,location,file,publisher,title,url,landing_page,sha256 +dwp-uc-england-2014-2019,ENGLAND,2014-2019,dwp_uc,uc_rate,england-universal-credit-local-housing-allowance-rates-2014-2019.ods,Department for Work and Pensions,England: Universal Credit Local Housing Allowance rates 2014 to 2019,https://assets.publishing.service.gov.uk/media/5d0b871740f0b6200473c662/england-universal-credit-local-housing-allowance-rates-2014-2019.ods,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates,c4325b036b770d908105c92903ee2ee296b84f6a2020ca0ea43e84c08807b706 +dwp-uc-england-2020,ENGLAND,2020,dwp_uc,uc_rate,england-rates-2020-to-2021.ods,Department for Work and Pensions,England: Universal Credit Local Housing Allowance rates 2020 to 2021,https://assets.publishing.service.gov.uk/media/606eedc3e90e076f57af8483/england-rates-2020-to-2021.ods,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2020-to-2021,20e7671066d6a4df042012426a387d27cec0aad491d72f7ac9154ab7ca3895a7 +dwp-uc-england-2021,ENGLAND,2021,dwp_uc,uc_rate,england-rates-2021-to-2022.ods,Department for Work and Pensions,England: Universal Credit Local Housing Allowance rates 2021 to 2022,https://assets.publishing.service.gov.uk/media/606eefbe8fa8f5735f2a2466/england-rates-2021-to-2022.ods,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2021-to-2022,a5bcd23f071bc376bf9e9e1bed2cc7a4afa684b43a70e8176724516fd45e1e48 +dwp-uc-england-2022,ENGLAND,2022,dwp_uc,uc_rate,england-rates-2022-to-2023.csv,Department for Work and Pensions,England: Universal Credit Local Housing Allowance monthly rates 2022 to 2023,https://assets.publishing.service.gov.uk/media/622f20118fa8f56c1ba9dc6f/england-rates-2022-to-2023.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2022-to-2023,7c0d5057e8b02965d2a3c9395766683031c910240a703b74af6e7501cb9439f6 +dwp-uc-england-2023,ENGLAND,2023,dwp_uc,uc_rate,england-rates-2023-to-2024.csv,Department for Work and Pensions,England: Universal Credit Local Housing Allowance monthly rates 2023 to 2024,https://assets.publishing.service.gov.uk/media/646f73c4ab40bf0010196aa7/england-rates-2023-to-2024.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2023-to-2024,4cbfd791663345bc21e4219cb694a470cb72cc369322d7ad0d254e6d9d1dce7f +dwp-uc-england-2024,ENGLAND,2024,dwp_uc,uc_rate,england-rates-2024-to-2025-revised.csv,Department for Work and Pensions,England: Universal Credit Local Housing Allowance monthly rates 2024 to 2025,https://assets.publishing.service.gov.uk/media/664b5562ae748c43d3793e90/england-rates-2024-to-2025-revised.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2024-to-2025,da7e106fbc88a7f07e3668eba354c7d4873ced3b7e5a26398b9ebb64617dbfcf +dwp-uc-england-2025,ENGLAND,2025,dwp_uc,uc_rate,england-rates-2025-to-2026.csv,Department for Work and Pensions,England: Universal Credit Local Housing Allowance monthly rates 2025 to 2026,https://assets.publishing.service.gov.uk/media/67a0990e1f9e7f7dcc7b3fa4/england-rates-2025-to-2026.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2025-to-2026,da7e106fbc88a7f07e3668eba354c7d4873ced3b7e5a26398b9ebb64617dbfcf +dwp-uc-england-2026,ENGLAND,2026,dwp_uc,uc_rate,england-rates-2026-to-2027.csv,Department for Work and Pensions,England: Universal Credit Local Housing Allowance monthly rates 2026 to 2027,https://assets.publishing.service.gov.uk/media/69d654a2e1430e837a86f64a/england-rates-2026-to-2027.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2026-to-2027,3b33d922342719b1970ff51b55f60dc4ea306d00bae63132c99b8977e89b0815 +dwp-uc-scotland-2014-2019,SCOTLAND,2014-2019,dwp_uc,uc_rate,scotland-universal-credit-local-housing-allowance-rates-2014-2019.ods,Department for Work and Pensions,Scotland: Universal Credit Local Housing Allowance rates 2014 to 2019,https://assets.publishing.service.gov.uk/media/5d0a272b40f0b6200f963ad2/scotland-universal-credit-local-housing-allowance-rates-2014-2019.ods,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates,79d8024319b55caaf2bf55ef1a06121c9e8aee44a83f46690a4eb127c1d26a53 +dwp-uc-scotland-2020,SCOTLAND,2020,dwp_uc,uc_rate,scotland-rates-2020-to_2021.ods,Department for Work and Pensions,Scotland: Universal Credit Local Housing Allowance rates 2020 to 2021,https://assets.publishing.service.gov.uk/media/606eedd7e90e076f4b1f130a/scotland-rates-2020-to_2021.ods,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2020-to-2021,911e067e55782284d9693d8a06a97825d637ba91112462f38a7047a1345c47b9 +dwp-uc-scotland-2021,SCOTLAND,2021,dwp_uc,uc_rate,scotland-rates-2021-to-2022.ods,Department for Work and Pensions,Scotland: Universal Credit Local Housing Allowance rates 2021 to 2022,https://assets.publishing.service.gov.uk/media/606eefd48fa8f573607a3352/scotland-rates-2021-to-2022.ods,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2021-to-2022,6d09206972dfa5018e1d251b5ec0eda76ab9796506b2c14b9ad6f1388e1772d8 +dwp-uc-scotland-2022,SCOTLAND,2022,dwp_uc,uc_rate,scotland-rates-2022-to-2023.csv,Department for Work and Pensions,Scotland: Universal Credit Local Housing Allowance monthly rates 2022 to 2023,https://assets.publishing.service.gov.uk/media/622f2031d3bf7f5a8b93abdc/scotland-rates-2022-to-2023.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2022-to-2023,0086bdb2400c3250a0f8dbe6f731dd9344414c806e05bf3667940b0a9ae40424 +dwp-uc-scotland-2023,SCOTLAND,2023,dwp_uc,uc_rate,scotland-rates-2023-to-2024.csv,Department for Work and Pensions,Scotland: Universal Credit Local Housing Allowance monthly rates 2023 to 2024,https://assets.publishing.service.gov.uk/media/646f73857dd6e7000ca9b3e0/scotland-rates-2023-to-2024.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2023-to-2024,2830a06f91fa1d42bede32e6c19c0eb9d7cd79c6fe546ac6578dffbaae47963a +dwp-uc-scotland-2024,SCOTLAND,2024,dwp_uc,uc_rate,scotland-rates-2024-to-2025.csv,Department for Work and Pensions,Scotland: Universal Credit Local Housing Allowance monthly rates 2024 to 2025,https://assets.publishing.service.gov.uk/media/65af9f578bbe95000e5eb288/scotland-rates-2024-to-2025.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2024-to-2025,3917552fe93983b40b9afd6a66f85dbc4de06d469834ce4694cabfeee2d706cb +dwp-uc-scotland-2025,SCOTLAND,2025,dwp_uc,uc_rate,scotland-rates-2025-to-2026.csv,Department for Work and Pensions,Scotland: Universal Credit Local Housing Allowance monthly rates 2025 to 2026,https://assets.publishing.service.gov.uk/media/67a0cc3bcae64da4967b3fd5/scotland-rates-2025-to-2026.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2025-to-2026,3917552fe93983b40b9afd6a66f85dbc4de06d469834ce4694cabfeee2d706cb +dwp-uc-scotland-2026,SCOTLAND,2026,dwp_uc,uc_rate,scotland-rates-2026-to-2027.csv,Department for Work and Pensions,Scotland: Universal Credit Local Housing Allowance monthly rates 2026 to 2027,https://assets.publishing.service.gov.uk/media/69d657392ea7bc150a394ae5/scotland-rates-2026-to-2027.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2026-to-2027,f14c8232dff86c458f592f6c5f3d2363cfc56c28701d98b80147e4c344a70f50 +dwp-uc-wales-2014-2019,WALES,2014-2019,dwp_uc,uc_rate,wales-universal-credit-local-housing-allowance-rates-2014-2019.ods,Department for Work and Pensions,Wales: Universal Credit Local Housing Allowance rates 2014 to 2019,https://assets.publishing.service.gov.uk/media/5d0a273c40f0b6200e8818a0/wales-universal-credit-local-housing-allowance-rates-2014-2019.ods,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates,52896731dbd11eb1d72d2664c37ba83fae59aea8c57db3e4c5202df4c910f3b4 +dwp-uc-wales-2020,WALES,2020,dwp_uc,uc_rate,welsh-rates-2020-to-2021.ods,Department for Work and Pensions,Wales: Universal Credit Local Housing Allowance rates 2020 to 2021,https://assets.publishing.service.gov.uk/media/606eede98fa8f5735b660282/welsh-rates-2020-to-2021.ods,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2020-to-2021,ca005ae5b7ad77ca4a49fc275d5cdc81ae78453cde96c3798cf6bca30e113f20 +dwp-uc-wales-2021,WALES,2021,dwp_uc,uc_rate,wales-rates-2021-to-2022.ods,Department for Work and Pensions,Wales: Universal Credit Local Housing Allowance rates 2021 to 2022,https://assets.publishing.service.gov.uk/media/606eeff88fa8f57363246b85/wales-rates-2021-to-2022.ods,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2021-to-2022,fbd2a917e5496d67008eebb520c63fcd86f85f5792187c58773ab3ef2cc44a6b +dwp-uc-wales-2022,WALES,2022,dwp_uc,uc_rate,wales-rates-2022-to-2023.csv,Department for Work and Pensions,Wales: Universal Credit Local Housing Allowance monthly rates 2022 to 2023,https://assets.publishing.service.gov.uk/media/622f204e8fa8f56c226e63f1/wales-rates-2022-to-2023.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2022-to-2023,01c67aa7bd756e84d75c66cfe153a2bc0a237e8c77afbb8ab3a0db8cf2427536 +dwp-uc-wales-2023,WALES,2023,dwp_uc,uc_rate,wales-rates-2023-to-2024.csv,Department for Work and Pensions,Wales: Universal Credit Local Housing Allowance monthly rates 2023 to 2024,https://assets.publishing.service.gov.uk/media/646f733aab40bf000c196a9e/wales-rates-2023-to-2024.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2023-to-2024,55b978cbc13c8c0d4ec44d48e40833b75fd49b04aceb0f214bc17dcb4f7146dd +dwp-uc-wales-2024,WALES,2024,dwp_uc,uc_rate,wales-rates-2024-to-2025.csv,Department for Work and Pensions,Wales: Universal Credit Local Housing Allowance monthly rates 2024 to 2025,https://assets.publishing.service.gov.uk/media/65ba3dfcee7d490013984a65/wales-rates-2024-to-2025.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2024-to-2025,1e4edb33428a476e6268649ea217f4f3c322d46e558839fbad10b1e1135c9f3b +dwp-uc-wales-2025,WALES,2025,dwp_uc,uc_rate,wales-rates-2025-to-2026.csv,Department for Work and Pensions,Wales: Universal Credit Local Housing Allowance monthly rates 2025 to 2026,https://assets.publishing.service.gov.uk/media/67a0cd47cae64da4967b3fd7/wales-rates-2025-to-2026.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2025-to-2026,1e4edb33428a476e6268649ea217f4f3c322d46e558839fbad10b1e1135c9f3b +dwp-uc-wales-2026,WALES,2026,dwp_uc,uc_rate,wales-rates-2026-to-2027.csv,Department for Work and Pensions,Wales: Universal Credit Local Housing Allowance monthly rates 2026 to 2027,https://assets.publishing.service.gov.uk/media/69d6574a28142a9bd207ba09/wales-rates-2026-to-2027.csv,https://www.gov.uk/government/publications/universal-credit-local-housing-allowance-rates-2026-to-2027,88fbcad674a785ec237edf13a1c5e6fa921c1375bfc0c49528b2f144e5654ed8 +scotland-2015,SCOTLAND,2015,gov_scot,rate=0;percentile_30=1,scotland-2015.html,Scottish Government (Rent Service Scotland),Local Housing Allowance Figures (2015-2016 archived original),https://web.archive.org/web/20150627210339id_/http://www.gov.scot/Topics/Built-Environment/Housing/privaterent/tenants/Local-Housing-Allowance/figures,https://web.archive.org/web/20150627210339id_/http://www.gov.scot/Topics/Built-Environment/Housing/privaterent/tenants/Local-Housing-Allowance/figures,fd099f86474bc77774aa3da04f30588c8dd85d535d25de7a9801fd32445a7102 +scotland-2016,SCOTLAND,2016,gov_scot,rate=0,scotland-2016.html,Scottish Government (Rent Service Scotland),Local Housing Allowance rates,https://www.gov.scot/publications/local-housing-allowance-figures/,https://www.gov.scot/publications/local-housing-allowance-figures/,122e5848c7aac8de7940b14a09412dace3e1c45e05e0b9003b5a417f4c9c65c2 +scotland-2016-methodology,SCOTLAND,2016,gov_scot,percentile_30=1,scotland-2016-methodology.html,Scottish Government (Rent Service Scotland),Local Housing Allowance Figures (archived original page),https://web.archive.org/web/20160409225147id_/http://www.gov.scot/Topics/Built-Environment/Housing/privaterent/tenants/Local-Housing-Allowance/figures,https://web.archive.org/web/20160409225147id_/http://www.gov.scot/Topics/Built-Environment/Housing/privaterent/tenants/Local-Housing-Allowance/figures,1c8d357ae1d411a8b40b0f613a2cd54c632e219bb1bb23b83ec99cc072a898d1 +scotland-2017,SCOTLAND,2017,gov_scot,rate=0;percentile_30=1,scotland-2017.html,Scottish Government (Rent Service Scotland),Local Housing Allowance rates 2017-2018,https://www.gov.scot/publications/local-housing-allowance-rates-2017/,https://www.gov.scot/publications/local-housing-allowance-rates-2017/,9f670ac61986599ab8f4f1224fb5766823525050c30430df98a27c6fff8b54e8 +scotland-2018,SCOTLAND,2018,gov_scot,rate=0;percentile_30=1,scotland-2018.html,Scottish Government (Rent Service Scotland),Local Housing Allowance rates 2018-2019,https://www.gov.scot/publications/local-housing-allowance-rates-2018-2019/,https://www.gov.scot/publications/local-housing-allowance-rates-2018-2019/,bbb5a35a180bfd7bd6512ada5108378bfd18c4707b65b64f51980eca348dedf5 +scotland-2019,SCOTLAND,2019,gov_scot,rate=0;percentile_30=1,scotland-2019.html,Scottish Government (Rent Service Scotland),Local Housing Allowance Rates: 2019-2020,https://www.gov.scot/publications/local-housing-allowance-rates-2019-2020/,https://www.gov.scot/publications/local-housing-allowance-rates-2019-2020/,53e8c28e04dedf636753706f4dd84ccd683bb7a652161dc57b839dba007e6f7c +scotland-2020,SCOTLAND,2020,gov_scot,rate=0;percentile_30=1,scotland-2020.html,Scottish Government (Rent Service Scotland),Local Housing Allowance Rates: 2020-2021,https://www.gov.scot/publications/local-housing-allowance-rates-2020-2021/,https://www.gov.scot/publications/local-housing-allowance-rates-2020-2021/,5662cd4c301d3d9765a1b62fe110c23eda7ca6622e66eae1e87836d83b8db716 +scotland-2021,SCOTLAND,2021,gov_scot,rate=0;percentile_30=1,scotland-2021.html,Scottish Government (Rent Service Scotland),Local Housing Allowance Rates: 2021-2022,https://www.gov.scot/publications/local-housing-allowance-rates-2021-2022/,https://www.gov.scot/publications/local-housing-allowance-rates-2021-2022/,c45891e88f9d0ac538566d4ff612c41439758f34e9cade08cdff3640a5083719 +scotland-2022,SCOTLAND,2022,gov_scot,rate=0;percentile_30=1,scotland-2022.html,Scottish Government (Rent Service Scotland),Local Housing Allowance Rates: 2022-2023,https://www.gov.scot/publications/local-housing-allowance-rates-2022-2023/,https://www.gov.scot/publications/local-housing-allowance-rates-2022-2023/,fd552ddd66362e870c1d9ee458870e86cb38e24a799f09404e30378ca14351fc +scotland-2023,SCOTLAND,2023,gov_scot,rate=0;percentile_30=1,scotland-2023.html,Scottish Government (Rent Service Scotland),Local Housing Allowance Rates: 2023-2024,https://www.gov.scot/publications/local-housing-allowance-rates-2023-2024/,https://www.gov.scot/publications/local-housing-allowance-rates-2023-2024/,0167e7295af56f0494af9f84894cdb0c8ef1bcf065f3ad2379b41084b21c026a +scotland-2024,SCOTLAND,2024,gov_scot,rate=0;percentile_30=1,scotland-2024.html,Scottish Government (Rent Service Scotland),Local Housing Allowance Rates: 2024-2025,https://www.gov.scot/publications/local-housing-allowance-rates-2024-2025/,https://www.gov.scot/publications/local-housing-allowance-rates-2024-2025/,0b8fbae790c04406282d161a6239e919e21fed11db6053599c2d45e66a27cc53 +scotland-2025,SCOTLAND,2025,gov_scot,rate=0;percentile_30=1,scotland-2025.html,Scottish Government (Rent Service Scotland),Local Housing Allowance Rates: 2025-2026,https://www.gov.scot/publications/local-housing-allowance-rates/pages/2025-to-2026/,https://www.gov.scot/publications/local-housing-allowance-rates/pages/2025-to-2026/,db4da07a6931cfb4bf52f608f74bc952929ffb0aacc6b45b413c1fc00538a202 +scotland-2026,SCOTLAND,2026,gov_scot,rate=0;percentile_30=1,scotland-2026.html,Scottish Government (Rent Service Scotland),Local Housing Allowance Rates: 2026-2027,https://www.gov.scot/publications/local-housing-allowance-rates/pages/2026-to-2027/,https://www.gov.scot/publications/local-housing-allowance-rates/pages/2026-to-2027/,f1b360b33efa0cf9327d125630847dd709beb08d80d7e689f9076a24066b1971 +nihe-hb-2015,NORTHERN_IRELAND,2015,nihe,hb_transposed,nihe-hb-2015.html,Northern Ireland Housing Executive,2015/2016 LHA rates,https://web.archive.org/web/20160420182640id_/http://www.nihe.gov.uk/index/benefits/lha/2014_lha_rates.htm,https://web.archive.org/web/20160420182640/http://www.nihe.gov.uk/index/benefits/lha/2014_lha_rates.htm,63775eddf65ab5acdea381f8a352bde75846b78a41ab336a3c2cce63abbf48ed +nihe-p30-2015,NORTHERN_IRELAND,2015,nihe,percentile_tables,nihe-p30-2015.html,Northern Ireland Housing Executive,LHA rates - how we calculate them: 2015/16,https://web.archive.org/web/20160312110430id_/http://www.nihe.gov.uk/index/benefits/lha/current_lha_rates/calculate_2014_rates.htm,https://web.archive.org/web/20160312110430/http://www.nihe.gov.uk/index/benefits/lha/current_lha_rates/calculate_2014_rates.htm,d6601e0120ddedfb58a40881a9f1386af198a784f06565ddea24e81e2411c7ff +nihe-hb-2016,NORTHERN_IRELAND,2016,nihe,hb_transposed,nihe-hb-2016.html,Northern Ireland Housing Executive,Current LHA rates: 1 April 2016 to 31 March 2017,https://web.archive.org/web/20160420182645id_/http://www.nihe.gov.uk/index/benefits/lha/current_lha_rates.htm,https://web.archive.org/web/20160420182645/http://www.nihe.gov.uk/index/benefits/lha/current_lha_rates.htm,143053a16f27d7b199751f4619a3f337804d35d09bd4539f14a6ce594f302aea +nihe-p30-2016,NORTHERN_IRELAND,2016,nihe,percentile_tables,nihe-p30-2016.html,Northern Ireland Housing Executive,LHA rates - how we calculate them: 2016,https://web.archive.org/web/20160412205823id_/http://www.nihe.gov.uk/index/benefits/lha/current_lha_rates/calculate_2014_rates.htm,https://web.archive.org/web/20160412205823/http://www.nihe.gov.uk/index/benefits/lha/current_lha_rates/calculate_2014_rates.htm,7b939e260b6550e2c835a89f5bddb98c61b25fabb56f4ece38d2a96bc3203ec2 +nihe-hb-2017,NORTHERN_IRELAND,2017,nihe,hb_transposed,nihe-hb-2017.html,Northern Ireland Housing Executive,Current LHA rates: 1 April 2017 to 31 March 2018,https://web.archive.org/web/20170505055401id_/http://www.nihe.gov.uk/index/benefits/lha/current_lha_rates.htm,https://web.archive.org/web/20170505055401/http://www.nihe.gov.uk/index/benefits/lha/current_lha_rates.htm,16c9f7fbc3c1b3fb5dbed6311cf1663ec6986083bb321e55cba792fc124894e6 +nihe-p30-2017,NORTHERN_IRELAND,2017,nihe,percentile_tables,nihe-p30-2017.html,Northern Ireland Housing Executive,LHA rates - how we calculate them: 2017,https://web.archive.org/web/20170509151051id_/http://www.nihe.gov.uk/index/benefits/lha/current_lha_rates/calculate_2014_rates.htm,https://web.archive.org/web/20170509151051/http://www.nihe.gov.uk/index/benefits/lha/current_lha_rates/calculate_2014_rates.htm,04744ace7b7b24eefe924b0d3672f663d2de2f2a1dfb7ddbd27fa78c7bad3771 +nihe-hb-2018,NORTHERN_IRELAND,2018,nihe,hb_transposed,nihe-hb-2018.html,Northern Ireland Housing Executive,Current LHA rates: 1 April 2018 to 31 March 2019,https://web.archive.org/web/20181126173900id_/https://www.nihe.gov.uk/index/benefits/lha/current_lha_rates.htm,https://web.archive.org/web/20181126173900/https://www.nihe.gov.uk/index/benefits/lha/current_lha_rates.htm,8d6d566305b65a22cddf1e982b5deda01f4c953901240aec52e0e7b964feb982 +nihe-p30-2018,NORTHERN_IRELAND,2018,nihe,percentile_accordion,nihe-p30-2018.html,Northern Ireland Housing Executive,How we calculate LHA rent levels: 2018,https://web.archive.org/web/20190923155210id_/https://www.nihe.gov.uk/Housing-Help/Local-Housing-Allowance/How-we-calculate-LHA-rent-levels,https://web.archive.org/web/20190923155210/https://www.nihe.gov.uk/Housing-Help/Local-Housing-Allowance/How-we-calculate-LHA-rent-levels,7b5fe26e35783a9f53b1d4f38341ab7d2063255c56687788e0d4290e460c00dc +nihe-hb-2019,NORTHERN_IRELAND,2019,nihe,hb_accordion,nihe-hb-2019.html,Northern Ireland Housing Executive,Current LHA rent levels: 1 April 2019 to 31 March 2020,https://web.archive.org/web/20190923151443id_/https://www.nihe.gov.uk/Housing-Help/Local-Housing-Allowance/Current-LHA-rent-levels,https://web.archive.org/web/20190923151443/https://www.nihe.gov.uk/Housing-Help/Local-Housing-Allowance/Current-LHA-rent-levels,2534b9cf321381da78f57fb6df7f3f1dd910209f5112454bc9071a6afd465449 +nihe-hb-2020,NORTHERN_IRELAND,2020,nihe,hb_accordion,nihe-hb-2020.html,Northern Ireland Housing Executive,LHA rates (1 April 2020 to 31 March 2021),https://web.archive.org/web/20200803233038id_/https://www.nihe.gov.uk/Housing-Help/Local-Housing-Allowance/Current-LHA-rent-levels,https://www.nihe.gov.uk/Housing-Help/Local-Housing-Allowance/Current-LHA-rent-levels,09bdfdd3eedfb9d14af97d076507f5d8eeb62ff60a08561a95d9bcc9a37266c0 +nihe-hb-2021,NORTHERN_IRELAND,2021,nihe,hb_accordion,nihe-hb-2021.html,Northern Ireland Housing Executive,LHA rates 1 April 2021 to 31 March 2022,https://web.archive.org/web/20220120224357id_/https://www.nihe.gov.uk/Housing-Help/Local-Housing-Allowance/Current-LHA-rent-levels,https://www.nihe.gov.uk/Housing-Help/Local-Housing-Allowance/Current-LHA-rent-levels,e78cee355e653fe45c1f53ab60b9a46d551fc1d905032c33af848af8e6351b50 +nihe-hb-2022,NORTHERN_IRELAND,2022,nihe,hb_accordion,nihe-hb-2022.html,Northern Ireland Housing Executive,LHA rates 1 April 2021 to 31 March 2023,https://web.archive.org/web/20230202104621id_/https://www.nihe.gov.uk/housing-help/local-housing-allowance/current-lha-rent-levels,https://www.nihe.gov.uk/housing-help/local-housing-allowance/current-lha-rent-levels,0a910478020b6de5eaa158354a92eb9c778893e615238b361abda61943de3c2c +nihe-hb-2023,NORTHERN_IRELAND,2023,nihe,hb_accordion,nihe-hb-2023.html,Northern Ireland Housing Executive,LHA rents 1 April 2023 to 31 March 2024,https://web.archive.org/web/20240615042702id_/https://www.nihe.gov.uk/housing-help/local-housing-allowance/previous-lha-rent-levels,https://www.nihe.gov.uk/housing-help/local-housing-allowance/previous-lha-rent-levels,c1b60d5d25dd4215bcab84a0a4624ef86f8207bfd3d5e0412d9762bb50a2d020 +ni-uc-2024-nidirect-archive,NORTHERN_IRELAND,2024,nihe,uc_table,ni-uc-2024-nidirect-archive.html,nidirect (Department for Communities),Universal Credit payments for housing,https://web.archive.org/web/20240920131132id_/https://www.nidirect.gov.uk/articles/universal-credit-payments-housing,https://www.nidirect.gov.uk/articles/universal-credit-payments-housing,d89ab34d019dc31fb07d1a4a287b8aaf44d156061df31dae114deeca2c4b53d0 +nihe-hb-2024,NORTHERN_IRELAND,2024,nihe,hb_accordion,nihe-hb-2024.html,Northern Ireland Housing Executive,LHA rents 1 April 2024 to 31 March 2025,https://web.archive.org/web/20240410023810id_/https://www.nihe.gov.uk/housing-help/local-housing-allowance/current-lha-rent-levels,https://www.nihe.gov.uk/housing-help/local-housing-allowance/current-lha-rent-levels,28926b1d94fc786514bd936db5d180c7572c519427675134b7be18847ab00337 +ni-uc-2025-nidirect-archive,NORTHERN_IRELAND,2025,nihe,uc_table,ni-uc-2025-nidirect-archive.html,nidirect (Department for Communities),Universal Credit payments for housing,https://web.archive.org/web/20260204225357id_/https://www.nidirect.gov.uk/articles/universal-credit-payments-housing,https://www.nidirect.gov.uk/articles/universal-credit-payments-housing,ef7b4470036d0ae148564f8efb43353a0d0e296e53bc50a9eba5ac63a827b2c4 +nihe-hb-2025,NORTHERN_IRELAND,2025,nihe,hb_accordion,nihe-hb-2025.html,Northern Ireland Housing Executive,LHA rents 1 April 2025 to 31 March 2026,https://web.archive.org/web/20260205034616id_/https://www.nihe.gov.uk/housing-help/local-housing-allowance/current-lha-rent-levels,https://www.nihe.gov.uk/housing-help/local-housing-allowance/current-lha-rent-levels,05ef593e5f4348d0375d6bdbde3c62b1708ba93bcc480b4b61661480ef8287f0 +ni-uc-2026-nidirect,NORTHERN_IRELAND,2026,nihe,uc_table,ni-uc-2026-nidirect.html,nidirect (Department for Communities),Universal Credit payments for housing,https://www.nidirect.gov.uk/articles/universal-credit-payments-housing,https://www.nidirect.gov.uk/articles/universal-credit-payments-housing,91c4b870490f4106bc534b104a8d5a7e3e90c956e059de224675d281e73158d7 +wales-2014,WALES,2014,rent_officers_wales,Table_1_-_Weekly,local-housing-allowance-rates-2014_0.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2014 to March 2015,https://www.gov.wales/sites/default/files/publications/2019-03/local-housing-allowance-rates-2014_0.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2014-march-2015,c50cacf199af30aa3b48a759a303ce20e3f0d259afe5cd4c011e4e2eada6635e +wales-2015,WALES,2015,rent_officers_wales,Table_1_-_Weekly,local-housing-allowance-rates-2015_0.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2015 to March 2016,https://www.gov.wales/sites/default/files/publications/2019-03/local-housing-allowance-rates-2015_0.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2015-march-2016,88e6af0a600af11343f54556c4c20c298c1d006f0bb3ac9f123eaed46abd6c21 +wales-2016,WALES,2016,rent_officers_wales,Table_1_-_Weekly,local-housing-allowance-rates-2016_0.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2016 to March 2017,https://www.gov.wales/sites/default/files/publications/2019-03/local-housing-allowance-rates-2016_0.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2016-march-2017,d348bed5db09bfb312ee3a9fad2f85c7dbd8c7c8e6cb27fe8f8ea6312751b213 +wales-2017,WALES,2017,rent_officers_wales,Table_1_-_Weekly,local-housing-allowance-rates-2017.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2017 to March 2018,https://www.gov.wales/sites/default/files/publications/2019-03/local-housing-allowance-rates-2017.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2017-march-2018,c696a4db25346abed541bc8821ff35caeec86add449e643cdeeb02902aa6dddc +wales-2018,WALES,2018,rent_officers_wales,Table_1_-_Weekly,local-housing-allowance-rates-2018.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2018 to March 2019,https://www.gov.wales/sites/default/files/publications/2019-03/local-housing-allowance-rates-2018.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2018-march-2019,36533c442d6ae29b7311569d10f6017277c3225f22474a7e3e51b0b2f4aa571a +wales-2019,WALES,2019,rent_officers_wales,Table_1_-_Weekly,190131-local-housing-allowance-rates-2019.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2019 to March 2020,https://www.gov.wales/sites/default/files/publications/2019-03/190131-local-housing-allowance-rates-2019.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2019-march-2020,0345367546331d4e47459f2639bac64594326507dd539146088ec13af63f212e +wales-2020,WALES,2020,rent_officers_wales,Table_1_-_Weekly,local-housing-allowance-rates-April-2020-March-2021.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2020 to March 2021,https://www.gov.wales/sites/default/files/publications/2020-03/local-housing-allowance-rates-April-2020-March-2021.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2020-march-2021,d1365d4697f0c2181a90ea201f72313fce9dd7fd0984296c322b041b7e25c5e2 +wales-2021,WALES,2021,rent_officers_wales,Table_1_-_Weekly,local-housing-allowance-rates-April-2021-March-2022.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2021 to March 2022,https://www.gov.wales/sites/default/files/publications/2021-02/local-housing-allowance-rates-April-2021-March-2022.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2021-march-2022,6c6a343cdca0eaec4dd1dd3121828255055dc4293caf22802967a510c41c88fc +wales-2022,WALES,2022,rent_officers_wales,Table_1_-_Weekly,local-housing-allowance-rates-April-2022-March-2023.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2022 to March 2023,https://www.gov.wales/sites/default/files/publications/2022-07/local-housing-allowance-rates-April-2022-March-2023.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2022-march-2023,eb76de13bec456bcd68e2d77f58da96730e532ac2240d58752e87728380d6d8f +wales-2023,WALES,2023,rent_officers_wales,Table_1_-_Weekly,local-housing-allowance-rates-April-2023-March-2024.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2023 to March 2024,https://www.gov.wales/sites/default/files/publications/2023-01/local-housing-allowance-rates-April-2023-March-2024.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2023-march-2024,97305eb4e90b98e9f3c815f590fd9b4ac4cf710d65dd3f1763113ab3e8d8415a +wales-2024,WALES,2024,rent_officers_wales,Table_1_-_Weekly,local-housing-allowance-rates-2024-2025.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2024 to March 2025,https://www.gov.wales/sites/default/files/publications/2024-01/local-housing-allowance-rates-2024-2025.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2024-march-2025,8dd9adab655996e604c53525eaa39415790c8fc1a3342ef8784c9412f62fd2ea +wales-2025,WALES,2025,rent_officers_wales,Table_1_-_Weekly,local-housing-allowance-rates-2025-2026.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2025 to March 2026,https://www.gov.wales/sites/default/files/publications/2025-01/local-housing-allowance-rates-2025-2026.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2025-march-2026,09ae3c93f940b4baa02ae7f7d3c99d0482ba152eebd1c6418c7eb98cff9e36b5 +wales-2026,WALES,2026,rent_officers_wales,Table_1_-_Weekly,local-housing-allowance-rates-2026-2027-17066.ods,Rent Officers Wales (Welsh Government),Local Housing Allowance (LHA) rates from April 2026 to March 2027,https://www.gov.wales/sites/default/files/publications/2026-01/local-housing-allowance-rates-2026-2027-17066.ods,https://www.gov.wales/local-housing-allowance-lha-rates-april-2026-march-2027,9d8b5f6704f28f6230fe35ac9ca6136d01e39e3615114a01aecea70f2e175397 +voa-2014,ENGLAND,2014,voa,rate=Table 4;percentile_30=Table 2,LHA_Tables_2014_-_2015.xls,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2014 to March 2015,https://assets.publishing.service.gov.uk/media/69c4f661471d520038d0f65d/LHA_Tables_2014_-_2015.xls,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2014-to-march-2015,0ad6c7b85c35cce23d7c9fef982b408498a84291f300712696c58416cdfaf029 +voa-2015,ENGLAND,2015,voa,rate=Table 4;percentile_30=Table 2,FINAL_2015_LHA_RATES.xls,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2015 to March 2016,https://assets.publishing.service.gov.uk/media/5a7e172a40f0b62305b80bcd/FINAL_2015_LHA_RATES.xls,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2015-march-2016,eb79f5a6ae295f96f48455f7fe183e8e04c8c8ce543141839bde3695dd1efe95 +voa-2016,ENGLAND,2016,voa,rate=Table 3;percentile_30=Table 2,FINAL_2016_LHA_RATES.xls,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2016 to March 2017,https://assets.publishing.service.gov.uk/media/5a815b85ed915d74e33fdc26/FINAL_2016_LHA_RATES.xls,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2016-march-2017,29dc6c6fa4d60bc2cb7e1f57cb8113b8aa5d2cdd71d7f7d96eba5b9403a4c127 +voa-2017,ENGLAND,2017,voa,rate=Table 5;percentile_30=Table 3,2017_LHA_TABLES.xlsx,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2017 to March 2018,https://assets.publishing.service.gov.uk/media/5a7f97d9ed915d74e622b686/2017_LHA_TABLES.xlsx,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2017-march-2018,beb5d794c11f5d4ebfea2fc4c07a93a2507c19268f5072fd9abeb8802b7309da +voa-2018,ENGLAND,2018,voa,rate=Table 4;percentile_30=Table 2,2018_LHA_TABLES.xlsx,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2018 to March 2019,https://assets.publishing.service.gov.uk/media/5a82d09240f0b6230269cd9b/2018_LHA_TABLES.xlsx,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2018-to-march-2019,cb85f7283bff37c1ebaa90de0c043b1a4e469b56eacb701c92d1bc443c5bb041 +voa-2019,ENGLAND,2019,voa,rate=Table 4;percentile_30=Table 2,2019-20_LHA_TABLES.xlsx,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2019 to March 2020,https://assets.publishing.service.gov.uk/media/5c52dcc540f0b62533d59eec/2019-20_LHA_TABLES.xlsx,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2019-to-march-2020,425f057dbe55628a96546b90f5bbc6d7503b67072fb5c2f4a19da56b1c3961db +voa-2020,ENGLAND,2020,voa,rate=Table 4;percentile_30=Table 2,AMENDED__CORVID_19__2020_21_LHA_TABLES.xlsx,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2020 to March 2021 - amendment as instructed by The Social Security (Coronavirus) (Further Measures) Regulations 2020,https://assets.publishing.service.gov.uk/media/5e7b556ed3bf7f1342511ff9/AMENDED__CORVID_19__2020_21_LHA_TABLES.xlsx,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2020-to-march-2021,77a224251d10829d535d9b8c9d9777111777b3afcf4ae100045bd7c673763f3a +voa-2021,ENGLAND,2021,voa,rate=Table 4;percentile_30=Table 2,2021-22_LHA_TABLES.xlsx,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2021 to March 2022,https://assets.publishing.service.gov.uk/media/60140805e90e07626a866b57/2021-22_LHA_TABLES.xlsx,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2021-to-march-2022,cb78e00802f19fb20e227ea3718c4fe58de10638f9a8bf4790548c561d8f96a6 +voa-2022,ENGLAND,2022,voa,rate=Table 4;percentile_30=Table 2,2022-23_LHA_TABLES.xlsx,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2022 to March 2023,https://assets.publishing.service.gov.uk/media/61f7c32c8fa8f53894502121/2022-23_LHA_TABLES.xlsx,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2022-to-march-2023,9fac718780e56f49c44a20e542457884d84d88567ede24e1435803fc58b7fe8d +voa-2023,ENGLAND,2023,voa,rate=Table 4;percentile_30=Table 2,2023-24_LHA_TABLES.xlsx,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2023 to March 2024,https://assets.publishing.service.gov.uk/media/63d90ab68fa8f518877e76ca/2023-24_LHA_TABLES.xlsx,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2023-to-march-2024,7d92584d55adabb0ef91185054fbc611063c329b208693b0a05b8baddaec1f58 +voa-2024,ENGLAND,2024,voa,rate=Table 4;percentile_30=Table 2,2024-25_LHA_TABLES.xlsx,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2024 to March 2025,https://assets.publishing.service.gov.uk/media/65d767f254f1e70011165894/2024-25_LHA_TABLES.xlsx,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2024-to-march-2025,c8933cf3053f0931e7dc0fe4543637790e630ba8cc60652da4a9dc7274e56442 +voa-2025,ENGLAND,2025,voa,rate=Table 4;percentile_30=Table 2,2025-26_LHA_TABLES.xlsx,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2025 to March 2026,https://assets.publishing.service.gov.uk/media/679c9493c496e5d3ddafb61d/2025-26_LHA_TABLES.xlsx,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2025-to-march-2026,828248b8aa5aaadd2c962076c77988ce1a4763bbebefae294b7f50f0786dfcbb +voa-2026,ENGLAND,2026,voa,rate=Table 4;percentile_30=Table 2,2026-27_LHA_TABLES.xlsx,Valuation Office Agency,Local Housing Allowance (LHA) rates applicable from April 2026 to March 2027,https://assets.publishing.service.gov.uk/media/697b8034f8f4a746d9572f47/2026-27_LHA_TABLES.xlsx,https://www.gov.uk/government/publications/local-housing-allowance-lha-rates-applicable-from-april-2026-to-march-2027,ea8664cc0d4e3c94c5e8ba3ab0373192531bf1099922a779fb24942c5ae59f6c diff --git a/policyengine_uk/parameters/gov/dwp/LHA/march_2020_minimum.yaml b/policyengine_uk/parameters/gov/dwp/LHA/march_2020_minimum.yaml new file mode 100644 index 0000000000..d77eb37a59 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/LHA/march_2020_minimum.yaml @@ -0,0 +1,19 @@ +description: While this parameter is true, a newly determined Local Housing Allowance rate cannot fall below the rate determined on 31 March 2020 for the same Broad Rental Market Area and category. The minimum applies to determinations made from 31 January 2024, so it first affects the April 2024 rates. +values: + 2015-01-01: false + 2024-01-31: + value: true + metadata: + reference: + - title: The Rent Officers (Housing Benefit and Universal Credit Functions) (Amendment) Order 2024, arts. 2(4), 3(4) and 4(4) + href: https://www.legislation.gov.uk/uksi/2024/11/made + - title: The Housing Benefit and Universal Credit Housing Costs (Executive Determinations) (Amendment) Regulations (Northern Ireland) 2024, regs. 2(4) and 3(4) + href: https://www.legislation.gov.uk/nisr/2024/3/made +metadata: + unit: bool + label: LHA minimum at March 2020 rates + reference: + - title: The Rent Officers (Housing Benefit Functions) Order 1997, Schedule 3B, paragraph 3A + href: https://www.legislation.gov.uk/uksi/1997/1984/schedule/3B + - title: The Rent Officers (Universal Credit Functions) Order 2013, Schedule 1, paragraph 7 + href: https://www.legislation.gov.uk/uksi/2013/382/schedule/1 diff --git a/policyengine_uk/parameters/gov/dwp/LHA/percentile.yaml b/policyengine_uk/parameters/gov/dwp/LHA/percentile.yaml index 4cb5f1dce5..9cf92c8e56 100644 --- a/policyengine_uk/parameters/gov/dwp/LHA/percentile.yaml +++ b/policyengine_uk/parameters/gov/dwp/LHA/percentile.yaml @@ -1,4 +1,4 @@ -description: Local Housing Allowance rates are set at this percentile of private rents in the family's Broad Rental Market Area. This parameter does not apply if LHA is frozen. +description: Local Housing Allowance rates are set at this percentile of private rents in the family's Broad Rental Market Area. The published tables give the 30th percentile; another value scales it by the ratio of the two percentiles in the model's list of rents for the same area and category (in Wales, Scotland and Northern Ireland, whose lists in the file are copies of English ones, the median English ratio for the category). This parameter does not apply if LHA is frozen, or to determinations before April 2020, which are taken as published. values: 2015-01-01: value: 0.3 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA.yaml index e3141fde06..99f86be42c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA.yaml @@ -1,12 +1,13 @@ - name: BRMA default value period: 2020 - absolute_error_margin: 20 + absolute_error_margin: 0.01 input: age: 18 LHA_category: C output: brma: MAIDSTONE - BRMA_LHA_rate: 9_771 + # VOA, LHA April 2020 (amended), Table 4: Maidstone two bedrooms, GBP 187.56 a week. + BRMA_LHA_rate: 9_753.12 - name: BRMA inputs period: 2020 absolute_error_margin: 0 @@ -16,11 +17,12 @@ brma: GUILDFORD - name: BRMA and category period: 2020 - absolute_error_margin: 20 + absolute_error_margin: 0.01 input: brma: GUILDFORD LHA_category: C output: brma: GUILDFORD LHA_category: C - BRMA_LHA_rate: 13_164 + # VOA, LHA April 2020 (amended), Table 4: Guildford two bedrooms, GBP 253.15 a week. + BRMA_LHA_rate: 13_163.80 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml index bf5af7f3f7..dd48013298 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/entitlement/housing_benefit_lha_order.yaml @@ -40,9 +40,9 @@ benunits: benunit: members: [person] - # The model's 2026 rate for this household (default BRMA, Maidstone, - # one-bedroom category) is also 9,467.64; it is an input here so the - # test pins the order of the cap and the taper, not the LHA table. + # An input, so the test pins the order of the cap and the taper, not + # the LHA table. (The model's 2026 rate for this household, from the + # published Maidstone one-bedroom rate of 172.60 a week, is 8,975.20.) housing_benefit_LHA_rate: 9_467.64 # Inputs, so the earnings disregard and the pension-age allowance # cannot move the taper. The applicable income input overrides the diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/lha_published_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/lha_published_rates.yaml new file mode 100644 index 0000000000..f9bc046807 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/lha_published_rates.yaml @@ -0,0 +1,265 @@ +# LHA rates against the published determinations. Housing Benefit rates are +# weekly (BRMA_LHA_rate annualises over 52 weeks); Universal Credit rates are +# monthly (uc_LHA_cap annualises over 12 months, below a rent that exceeds it). +# England: VOA, Local Housing Allowance (LHA) rates applicable from April , +# https://www.gov.uk/government/collections/local-housing-allowance-lha-rates +# Universal Credit: DWP, Universal Credit Local Housing Allowance rates, +# https://www.gov.uk/government/collections/universal-credit-local-housing-allowance-rates + +- name: Maidstone shared accommodation, April 2024 (GBP 102.37 a week) + period: 2024 + absolute_error_margin: 0.01 + input: + brma: MAIDSTONE + LHA_category: A + output: + BRMA_LHA_rate: 5_323.24 + +- name: Maidstone one bedroom, April 2024 (GBP 172.60 a week) + period: 2024 + absolute_error_margin: 0.01 + input: + brma: MAIDSTONE + LHA_category: B + output: + BRMA_LHA_rate: 8_975.20 + +- name: Maidstone two bedrooms, April 2024 (GBP 208.27 a week; UC GBP 905.00 a month) + period: 2024 + absolute_error_margin: 0.01 + input: + brma: MAIDSTONE + LHA_category: C + benunit_rent: 100_000 + output: + BRMA_LHA_rate: 10_830.04 + uc_LHA_cap: 10_860 + +- name: Maidstone three bedrooms, April 2024 (GBP 276.16 a week) + period: 2024 + absolute_error_margin: 0.01 + input: + brma: MAIDSTONE + LHA_category: D + output: + BRMA_LHA_rate: 14_360.32 + +- name: Maidstone four bedrooms, April 2024 (GBP 356.71 a week) + period: 2024 + absolute_error_margin: 0.01 + input: + brma: MAIDSTONE + LHA_category: E + output: + BRMA_LHA_rate: 18_548.92 + +- name: Maidstone two bedrooms, April 2025, held at April 2024 (SI 2025/5) + period: 2025 + absolute_error_margin: 0.01 + input: + brma: MAIDSTONE + LHA_category: C + benunit_rent: 100_000 + output: + BRMA_LHA_rate: 10_830.04 + uc_LHA_cap: 10_860 + +- name: Maidstone two bedrooms, April 2026, held at April 2024 (SI 2026/5) + period: 2026 + absolute_error_margin: 0.01 + input: + brma: MAIDSTONE + LHA_category: C + benunit_rent: 100_000 + output: + BRMA_LHA_rate: 10_830.04 + uc_LHA_cap: 10_860 + +- name: Maidstone two bedrooms, April 2023, held at April 2020 (SI 2023/6) + period: 2023 + absolute_error_margin: 0.01 + input: + brma: MAIDSTONE + LHA_category: C + benunit_rent: 100_000 + output: + BRMA_LHA_rate: 9_753.12 + uc_LHA_cap: 9_779.88 + +- name: Maidstone two bedrooms, April 2019, as published (GBP 162.29 a week; UC GBP 703.24 a month) + period: 2019 + absolute_error_margin: 0.01 + input: + brma: MAIDSTONE + LHA_category: C + benunit_rent: 100_000 + output: + BRMA_LHA_rate: 8_439.08 + uc_LHA_cap: 8_438.88 + +- name: Durham one bedroom, April 2024, raised to the shared rate (Sch 3B para 3) + # The 30th percentile one-bedroom rent (GBP 80.55) is below the shared + # accommodation rate (GBP 90.50), so the one-bedroom rate is raised to it. + period: 2024 + absolute_error_margin: 0.01 + input: + brma: DURHAM + LHA_category: B + benunit_rent: 100_000 + output: + uncapped_BRMA_LHA_rate: 4_188.60 + BRMA_LHA_rate: 4_706 + uc_LHA_cap: 4_719 + +- name: Central London two bedrooms, April 2024, at the national maximum + # 30th percentile GBP 598.36 a week; weekly maximum GBP 412.86 and monthly + # maximum GBP 1,793.98 (SI 2024/11). + period: 2024 + absolute_error_margin: 0.01 + input: + brma: CENTRAL_LONDON + LHA_category: C + benunit_rent: 100_000 + output: + uncapped_BRMA_LHA_rate: 31_114.72 + BRMA_LHA_rate: 21_468.72 + uc_LHA_cap: 21_527.76 + +# Wales: Rent Officers Wales, Local Housing Allowance (LHA) rates from April +# , https://www.gov.wales/local-housing-allowance-lha-rates + +- name: Carmarthenshire one and two bedrooms, April 2024, rising with size + # The list-of-rents model gave the two-bedroom rate below the one-bedroom. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + person_2: + age: 40 + benunits: + benunit: + members: [person] + LHA_category: B + benunit_rent: 100_000 + benunit_2: + members: [person_2] + LHA_category: C + benunit_rent: 100_000 + households: + household: + members: [person, person_2] + brma: CARMARTHENSHIRE + output: + # GBP 92.05 and GBP 112.50 a week; UC GBP 400.00 and GBP 488.84 a month. + BRMA_LHA_rate: [4_786.60, 5_850] + uc_LHA_cap: [4_800, 5_866.08] + +- name: Taff Rhondda three bedrooms, April 2025, held at April 2024 + period: 2025 + absolute_error_margin: 0.01 + input: + brma: TAFF_RHONDDA + LHA_category: D + benunit_rent: 100_000 + output: + # GBP 113.92 a week; UC GBP 495.00 a month. + BRMA_LHA_rate: 5_923.84 + uc_LHA_cap: 5_940 + +- name: Blaenau Gwent one bedroom, April 2021, held at April 2020 + period: 2021 + absolute_error_margin: 0.01 + input: + brma: BLAENAU_GWENT + LHA_category: B + benunit_rent: 100_000 + output: + # GBP 66.39 a week; UC GBP 288.49 a month (DWP prints 288.488). + BRMA_LHA_rate: 3_452.28 + uc_LHA_cap: 3_461.88 + +- name: Blaenau Gwent one bedroom, April 2022, as Rent Officers Wales restated it + # The April 2022 table says rates are fixed at the April 2020 rate but gives + # GBP 66.74 a week (GBP 290.00 a month for UC), where the April 2020 and + # April 2021 tables gave GBP 66.39. + period: 2022 + absolute_error_margin: 0.01 + input: + brma: BLAENAU_GWENT + LHA_category: B + benunit_rent: 100_000 + output: + BRMA_LHA_rate: 3_470.48 + uc_LHA_cap: 3_480 + +# Scotland: Scottish Government (Rent Service Scotland), Local Housing +# Allowance rates, https://www.gov.scot/publications/local-housing-allowance-rates/ + +- name: Lothian two bedrooms, April 2026, held at April 2024 + period: 2026 + absolute_error_margin: 0.01 + input: + brma: LOTHIAN + LHA_category: C + benunit_rent: 100_000 + output: + # GBP 223.23 a week; UC GBP 970.00 a month. + BRMA_LHA_rate: 11_607.96 + uc_LHA_cap: 11_640 + +- name: Greater Glasgow three bedrooms, April 2020 + period: 2020 + absolute_error_margin: 0.01 + input: + brma: GREATER_GLASGOW + LHA_category: D + benunit_rent: 100_000 + output: + # GBP 172.60 a week; UC GBP 750.00 a month. + BRMA_LHA_rate: 8_975.20 + uc_LHA_cap: 9_000 + +# Northern Ireland: NIHE, Current LHA rent levels; Universal Credit rates from +# nidirect, Universal Credit payments for housing. + +- name: North (NI) two and three bedrooms, April 2024, rising with size + # The list-of-rents model gave the three-bedroom rate below the two-bedroom. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + person_2: + age: 40 + benunits: + benunit: + members: [person] + LHA_category: C + benunit_rent: 100_000 + benunit_2: + members: [person_2] + LHA_category: D + benunit_rent: 100_000 + households: + household: + members: [person, person_2] + brma: NORTH_NI + output: + # GBP 123.32 and GBP 133.48 a week; UC GBP 535.85 and GBP 580.00 a month. + BRMA_LHA_rate: [6_412.64, 6_940.96] + uc_LHA_cap: [6_430.20, 6_960] + +- name: Lough Neagh Lower three bedrooms, April 2026, held at April 2024 (SR 2026/2) + period: 2026 + absolute_error_margin: 0.01 + input: + brma: LOUGH_NEAGH_LOWER + LHA_category: D + benunit_rent: 100_000 + output: + # GBP 137.31 a week (April 2024); UC GBP 596.64 a month (nidirect, 2026-27). + BRMA_LHA_rate: 7_140.12 + uc_LHA_cap: 7_159.68 diff --git a/policyengine_uk/tests/policy/integration/entitledto_scenarios.yaml b/policyengine_uk/tests/policy/integration/entitledto_scenarios.yaml index d6c407cf8d..55c721df7a 100644 --- a/policyengine_uk/tests/policy/integration/entitledto_scenarios.yaml +++ b/policyengine_uk/tests/policy/integration/entitledto_scenarios.yaml @@ -30,10 +30,13 @@ output: income_tax: 0 national_insurance: 0 - universal_credit: 3_824.96 + # No BRMA is given, so the default (Maidstone) applies: a single person + # under 35 gets the shared accommodation rate, published at GBP 444.83 a + # month for Universal Credit from April 2024 and held for 2025-26. + universal_credit: 3_539.64 housing_benefit: 0 child_benefit: 0 - household_net_income: 14_450.42 + household_net_income: 14_165.10 - name: "EntitledTo #2 - couple with 2 children, one earner at £22k, social housing in North West" period: 2025 diff --git a/policyengine_uk/tests/test_lha_freeze.py b/policyengine_uk/tests/test_lha_freeze.py index 9188840f4d..d124cb3704 100644 --- a/policyengine_uk/tests/test_lha_freeze.py +++ b/policyengine_uk/tests/test_lha_freeze.py @@ -131,7 +131,8 @@ def test_a_reset_year_is_not_treated_as_a_hold(): 2020, "MAIDSTONE", "C", reform={"gov.dwp.LHA.freeze": {"2020": True}} ) - assert reset == pytest.approx(187.91, abs=0.01) + # VOA, LHA April 2020 (amended), Table 4: Maidstone two bedrooms. + assert reset == pytest.approx(187.56, abs=0.01) assert held_instead != pytest.approx(reset, abs=0.01) diff --git a/policyengine_uk/tests/test_lha_published_rates.py b/policyengine_uk/tests/test_lha_published_rates.py new file mode 100644 index 0000000000..c046f7e476 --- /dev/null +++ b/policyengine_uk/tests/test_lha_published_rates.py @@ -0,0 +1,625 @@ +"""LHA rates against the published determinations. + +The model recomputes each determination from April 2020 from the published +30th percentile rents, the national maxima and the rules of Schedule 3B to the +Rent Officers (Housing Benefit Functions) Order 1997 (and Schedule 1 to the +Universal Credit Functions Order 2013). These tests check that the recomputed +rates equal every published rate, and that the rules keep the properties the +law gives them under any freeze, percentile or maximum reform. +""" + +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import CountryTaxBenefitSystem +from policyengine_uk.utils.lha import ( + CATEGORIES, + FIRST_RULES_YEAR, + HELD_TABLES, + PUBLISHED_RATES_PATH, + determination_year, + lha_rates, + published_rates, + restatement, + statutory_percentile, +) +from policyengine_uk.variables.household.demographic.locations import BRMAName + +SYSTEM = CountryTaxBenefitSystem() +TABLE = pd.read_csv(PUBLISHED_RATES_PATH) +LAST_PUBLISHED_YEAR = int(TABLE.year.max()) + +NORTHERN_IRELAND = { + "BELFAST", + "LOUGH_NEAGH_LOWER", + "LOUGH_NEAGH_UPPER", + "NORTH_NI", + "NORTH_WEST_NI", + "SOUTH_EAST_NI", + "SOUTH_NI", + "SOUTH_WEST_NI", +} + + +def wide(measure: str) -> pd.DataFrame: + return TABLE.pivot_table( + index=["year", "brma"], columns="lha_category", values=measure + )[list(CATEGORIES)] + + +def test_every_brma_has_rates_for_every_determination_since_2020(): + """No BRMA falls back to a missing rate in a year the rules cover. + + Two gaps are known. No NIHE table for April 2026 could be retrieved; SR + 2026/2 holds Northern Ireland's rates at the January 2024 determination, + which the model applies. And Northern Ireland's monthly Universal Credit + rates are published (on nidirect) only from April 2024; before then the + model converts the weekly percentile. + """ + every = {member.name for member in BRMAName} + for year in range(FIRST_RULES_YEAR, LAST_PUBLISHED_YEAR + 1): + rows = TABLE[(TABLE.year == year) & TABLE.rate.notna()] + expected = every - NORTHERN_IRELAND if year == 2026 else every + assert set(rows.brma) == expected, year + assert rows.groupby("brma").size().eq(len(CATEGORIES)).all(), year + uc = TABLE[(TABLE.year == year) & TABLE.uc_rate.notna()] + expected = every if year >= 2024 else every - NORTHERN_IRELAND + assert set(uc.brma) == expected, year + assert uc.groupby("brma").size().eq(len(CATEGORIES)).all(), year + + +def test_published_rates_never_fall_with_dwelling_size(): + """Schedule 3B paragraph 3 makes each category at least the one before. + + The raw percentile rents need not be (shared rooms can let for more than + one-bedroom flats), which is why the paragraph exists. + """ + for measure in ("rate", "uc_rate"): + rates = wide(measure).dropna() + falls = rates[(np.diff(rates.to_numpy(), axis=1) < 0).any(axis=1)] + assert falls.empty, f"{measure} falls with size:\n{falls}" + + +WALES = { + "BLAENAU_GWENT", + "BRECON_AND_RADNOR", + "BRIDGEND", + "CAERPHILLY", + "CARDIFF", + "CARMARTHENSHIRE", + "CEREDIGION", + "FLINTSHIRE", + "MERTHYR_CYNON", + "MONMOUTHSHIRE", + "NEATH_PORT_TALBOT", + "NEWPORT", + "NORTH_CLWYD", + "NORTH_POWYS", + "NORTH_WEST_WALES", + "PEMBROKESHIRE", + "SOUTH_GWYNEDD", + "SWANSEA", + "TAFF_RHONDDA", + "TORFAEN", + "VALE_OF_GLAMORGAN", + "WREXHAM", +} + + +# Rent Officers Wales's April 2022 and April 2023 tables (and DWP's monthly +# tables for Wales in those years) restate these April 2020 rates, which they +# say they hold: (BRMA, category, April 2020 rate, restated rate). +WELSH_RESTATEMENTS = { + "rate": [ + ("BLAENAU_GWENT", "B", 66.39, 66.74), + ("BRECON_AND_RADNOR", "B", 71.86, 71.34), + ("BRIDGEND", "E", 156.26, 155.34), + ("CAERPHILLY", "B", 79.17, 79.40), + ("CARDIFF", "A", 71.11, 71.34), + ("MONMOUTHSHIRE", "B", 95.57, 95.51), + ("MONMOUTHSHIRE", "E", 179.74, 178.36), + ("NEATH_PORT_TALBOT", "B", 79.55, 79.40), + ("NEATH_PORT_TALBOT", "E", 121.40, 120.82), + ("SOUTH_GWYNEDD", "E", 121.20, 120.82), + ("SWANSEA", "E", 166.16, 165.70), + ("TAFF_RHONDDA", "E", 137.51, 136.93), + ("TORFAEN", "B", 87.31, 87.45), + ("VALE_OF_GLAMORGAN", "B", 100.63, 100.00), + ], + "uc_rate": [ + ("BLAENAU_GWENT", "B", 288.49, 290.00), + ("BRECON_AND_RADNOR", "B", 312.25, 310.00), + ("BRIDGEND", "E", 679.00, 675.00), + ("CAERPHILLY", "B", 344.00, 345.00), + ("CARDIFF", "A", 309.00, 310.00), + ("MONMOUTHSHIRE", "B", 415.27, 415.00), + ("MONMOUTHSHIRE", "C", 550.02, 550.00), + ("MONMOUTHSHIRE", "E", 781.00, 775.00), + ("NEATH_PORT_TALBOT", "B", 345.66, 345.00), + ("NEATH_PORT_TALBOT", "E", 527.50, 525.00), + ("NEWPORT", "E", 749.99, 750.00), + ("PEMBROKESHIRE", "E", 625.02, 625.00), + ("SOUTH_GWYNEDD", "E", 526.65, 525.00), + ("SWANSEA", "E", 722.00, 720.00), + ("TAFF_RHONDDA", "E", 597.50, 595.00), + ("TORFAEN", "B", 379.38, 380.00), + ("VALE_OF_GLAMORGAN", "B", 437.26, 434.52), + ], +} + + +@pytest.mark.parametrize("frozen_year,determined_year", sorted(HELD_TABLES.items())) +@pytest.mark.parametrize("measure", ["rate", "uc_rate"]) +def test_published_frozen_years_repeat_the_last_determination( + frozen_year, determined_year, measure +): + """SI 2020/1519, 2021/1380, 2023/6, 2025/5 and 2026/5 hold every rate. + + The one exception is the Welsh restatements above, pinned cell by cell + and amount by amount, so that any other difference fails here. + """ + rates = wide(measure) + held = rates.loc[frozen_year].dropna() + determined = rates.loc[determined_year].loc[held.index] + differs = (held - determined).abs() > 0.004 + rows, columns = np.nonzero(differs.to_numpy()) + found = sorted( + ( + held.index[i], + CATEGORIES[j], + round(float(determined.iloc[i, j]), 2), + round(float(held.iloc[i, j]), 2), + ) + for i, j in zip(rows, columns) + ) + expected = ( + sorted(WELSH_RESTATEMENTS[measure]) + if (frozen_year, determined_year) in ((2022, 2020), (2023, 2020)) + else [] + ) + assert found == expected + + +@pytest.mark.parametrize("year", range(int(TABLE.year.min()), LAST_PUBLISHED_YEAR + 1)) +@pytest.mark.parametrize("universal_credit", [False, True]) +def test_model_reproduces_every_published_rate(year, universal_credit): + """A differential test: the rules against the published tables.""" + measure = "uc_rate" if universal_credit else "rate" + model = lha_rates(SYSTEM.parameters, year, universal_credit) + published = published_rates().at(measure, year) + have = ~np.isnan(published) + assert not np.isnan(model["rate"][have]).any() + mismatches = np.abs(model["rate"] - published) > 0.004 + rows, columns = np.nonzero(have & mismatches) + assert len(rows) == 0, [ + (model["brmas"][i], CATEGORIES[j], model["rate"][i, j], published[i, j]) + for i, j in zip(rows[:10], columns[:10]) + ] + + +@pytest.mark.parametrize("year", range(2015, 2041)) +def test_baseline_rates_are_defined_and_never_fall_with_size(year): + for universal_credit in (False, True): + rate = lha_rates(SYSTEM.parameters, year, universal_credit)["rate"] + assert np.isfinite(rate).all() and (rate > 0).all(), year + assert (np.diff(rate, axis=1) >= 0).all(), year + + +def test_years_map_to_april_determinations(): + """Parameters are read at 30 April, so a year is the fiscal year from April. + + The freeze for determinations in 2025 starts on 6 April 2025, so the 2025 + fiscal year is frozen at the January 2024 determination; the 2024 fiscal + year carries the April 2024 reset. + """ + lha = SYSTEM.parameters.gov.dwp.LHA + assert determination_year(lha, 2024) == 2024 + assert determination_year(lha, 2025) == 2024 + assert determination_year(lha, 2026) == 2024 + assert determination_year(lha, 2023) == 2020 + assert determination_year(lha, 2020) == 2020 + + +def test_statutory_percentile_positions(): + """Sch 3B para 2(8): mean of positions P and P+1 when 0.3N is whole.""" + ten = np.arange(1.0, 11.0) + assert statutory_percentile(ten, 0.3) == pytest.approx(3.5) + eleven = np.arange(1.0, 12.0) + # 0.3 * 11 = 3.3, rounded up to position 4. + assert statutory_percentile(eleven, 0.3) == pytest.approx(4.0) + + +# Property tests over reforms to the determination rules. + + +def reformed_parameters( + freeze: dict, percentile: float, maximum_scale: float, minimum: bool +): + """Copies of the LHA parameters with reforms applied by year.""" + lha = SYSTEM.parameters.gov.dwp.LHA.clone() + for year, frozen in freeze.items(): + lha.freeze.update(period=str(year), value=frozen) + for year in range(2020, 2041): + lha.percentile.update(period=str(year), value=percentile) + lha.march_2020_minimum.update(period=str(year), value=minimum) + for node in (lha.maximum, lha.maximum_monthly): + for category in CATEGORIES: + parameter = node.children[category] + parameter.update( + period=str(year), + value=parameter(str(year)) * maximum_scale, + ) + return SimpleNamespace( + gov=SimpleNamespace( + dwp=SimpleNamespace(LHA=lha), + indices=SYSTEM.parameters.gov.indices, + ) + ) + + +reform_strategy = st.fixed_dictionaries( + dict( + freeze=st.dictionaries(st.integers(2020, 2032), st.booleans(), max_size=6), + percentile=st.sampled_from([0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.75, 0.9]), + # Non-round scales give caps in fractions of a penny. + maximum_scale=st.sampled_from([0.25, 0.333333, 0.5, 0.7777, 1.0, 1.5]), + minimum=st.booleans(), + ) +) + + +def whole_pence(values) -> bool: + pence = np.asarray(values) * 100 + return bool((np.abs(pence - np.round(pence)) < 1e-6).all()) + + +def independent_march_2020_floor(universal_credit: bool) -> np.ndarray: + """The 31 March 2020 rates, computed here rather than by the model. + + The April 2020 tables, with Rent Officers Wales's restatements (pinned in + ``WELSH_RESTATEMENTS``) applied, and Northern Ireland's monthly figures + converted from its weekly ones. + """ + rates = published_rates() + + def restated(measure): + values = rates.at(measure, FIRST_RULES_YEAR).copy() + for brma, category, original, held in WELSH_RESTATEMENTS[measure]: + i, j = rates.brmas.get_loc(brma), CATEGORIES.index(category) + assert values[i, j] == pytest.approx(original, abs=0.001) + values[i, j] = held + return values + + weekly = restated("rate") + if not universal_credit: + return weekly + monthly = restated("uc_rate") + converted = np.floor(np.round(weekly * 365 / 84 * 100, 6) + 0.5) / 100 + return np.where(np.isnan(monthly), converted, monthly) + + +@settings( + max_examples=40, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + reform=reform_strategy, + year=st.integers(2020, 2032), + universal_credit=st.booleans(), +) +def test_reformed_rates_keep_the_statutory_properties(reform, year, universal_credit): + parameters = reformed_parameters(**reform) + lha = parameters.gov.dwp.LHA + measure = "uc_rate" if universal_credit else "rate" + result = lha_rates(parameters, year, universal_credit) + rate, percentile = result["rate"], result["percentile"] + + # Defined, positive, whole pence (para 2(10)) and never falling with + # dwelling size (para 3), in every year, held or not. + assert np.isfinite(rate).all() and (rate > 0).all() + assert (np.diff(rate, axis=1) >= 0).all() + assert whole_pence(rate) + + determined = determination_year(lha, year) + if determined != year: + # A held year repeats the determination it is held at, wherever the + # published tables do not restate the held rates. + held = lha_rates(parameters, determined, universal_credit)["rate"] + unrestated = (restatement(measure, determined, year) == 0).all(axis=1) + np.testing.assert_array_equal(rate[unrestated], held[unrestated]) + if determined < FIRST_RULES_YEAR: + # Held at a determination the model takes as published. + return + + # The bounds the schedule puts on the rates in force, held years included. + maxima = lha.maximum_monthly if universal_credit else lha.maximum + cap = np.array([maxima.children[c](str(determined)) for c in CATEGORIES]) + cap = np.floor(np.round(cap * 100, 6) + 0.5) / 100 + floor = independent_march_2020_floor(universal_credit) + assert not np.isnan(floor).any() + applies = bool(lha.march_2020_minimum(str(determined))) + # Never above the larger of the category maximum (raised by para 3 to any + # smaller category's) and the minimum; never below the minimum while it + # applies (para 3A); never below the lower of percentile and maximum. + ceiling = np.maximum(np.maximum.accumulate(cap)[None, :], floor if applies else 0) + assert (rate <= ceiling + 1e-9).all() + if applies: + assert (rate >= floor - 1e-9).all() + assert (rate >= np.minimum(percentile, cap[None, :]) - 1e-9).all() + + +@settings(max_examples=25, deadline=None) +@given( + year=st.integers(2020, 2032), + low=st.sampled_from([0.1, 0.2, 0.3, 0.4]), + step=st.sampled_from([0.05, 0.1, 0.3]), + universal_credit=st.booleans(), +) +def test_a_higher_percentile_never_lowers_a_rate(year, low, step, universal_credit): + unfrozen = {y: False for y in range(2020, 2033)} + lower = lha_rates( + reformed_parameters(unfrozen, low, 1.0, True), year, universal_credit + )["rate"] + higher = lha_rates( + reformed_parameters(unfrozen, low + step, 1.0, True), year, universal_credit + )["rate"] + assert (higher >= lower - 1e-9).all() + + +# Reforms through a simulation. + + +def _weekly(year: int, brma: str, category: str, reform=None) -> float: + situation = { + "people": {"person": {"age": {year: 35}}}, + "benunits": { + "benunit": {"members": ["person"], "LHA_category": {year: category}} + }, + "households": {"household": {"members": ["person"], "brma": {year: brma}}}, + } + annual = SYSTEM_SIMULATION(situation, reform).calculate("BRMA_LHA_rate", year) + return float(annual[0]) / 52 + + +def SYSTEM_SIMULATION(situation, reform): + from policyengine_uk import Simulation + + return Simulation(situation=situation, reform=reform) + + +def test_unfreezing_a_published_year_uses_that_years_percentile(): + """The VOA publishes the percentile rents even for frozen years. + + Lifting the 2025 freeze therefore gives the rate that a 2025 determination + would have produced: Maidstone two bedrooms, 30th percentile GBP 230.14 + (VOA, LHA April 2025, Table 2), against the held GBP 208.27. + """ + assert _weekly(2025, "MAIDSTONE", "C") == pytest.approx(208.27, abs=0.001) + unfrozen = _weekly( + 2025, "MAIDSTONE", "C", reform={"gov.dwp.LHA.freeze": {"2025": False}} + ) + assert unfrozen == pytest.approx(230.14, abs=0.001) + + +def test_unfreezing_after_the_last_table_grows_the_latest_percentile(): + """Beyond the published tables, the latest percentile rent grows with rents.""" + index = SYSTEM.parameters.gov.indices.private_rent_index + expected = ( + np.floor(np.round(253.15 * index("2027") / index("2026") * 100, 6) + 0.5) / 100 + ) + unfrozen = _weekly( + 2027, + "MAIDSTONE", + "C", + reform={"gov.dwp.LHA.freeze": {"2027": False}}, + ) + assert unfrozen == pytest.approx(expected, abs=0.001) + assert unfrozen > 253.15 + + +def test_the_minimum_holds_rates_at_march_2020_levels(): + """Sch 3B para 3A: a lower percentile cannot take a rate below April 2020.""" + floor = 187.56 # Maidstone two bedrooms, LHA April 2020. + low = _weekly( + 2024, "MAIDSTONE", "C", reform={"gov.dwp.LHA.percentile": {"2024": 0.01}} + ) + assert low == pytest.approx(floor, abs=0.001) + without_minimum = _weekly( + 2024, + "MAIDSTONE", + "C", + reform={ + "gov.dwp.LHA.percentile": {"2024": 0.01}, + "gov.dwp.LHA.march_2020_minimum": {"2024": False}, + }, + ) + assert without_minimum < floor + + +def test_freezing_the_2024_reset_holds_the_2020_rates(): + held = _weekly( + 2024, "MAIDSTONE", "C", reform={"gov.dwp.LHA.freeze": {"2024": True}} + ) + assert held == pytest.approx(187.56, abs=0.001) + + +def test_welsh_and_ni_percentile_ratios_use_the_english_median(): + """The Welsh and NI lists of rents in the file copy English ones. + + So a percentile reform scales their published rates by the median English + ratio for the category, not by a copied list's shape. + """ + from policyengine_uk.utils.lha import ( + NON_ENGLISH_REGIONS, + _percentile_ratios, + _sorted_list_of_rents, + statutory_percentile, + ) + + lists, regions = _sorted_list_of_rents() + ratios = _percentile_ratios(0.5) + brmas = published_rates().brmas + english = {category: [] for category in "ABCDE"} + for (brma, category), rents in lists.items(): + if regions[brma] not in NON_ENGLISH_REGIONS: + english[category].append( + statutory_percentile(rents, 0.5) / statutory_percentile(rents, 0.3) + ) + median = [np.median(english[category]) for category in "ABCDE"] + for b in ("CARDIFF", "BELFAST"): + np.testing.assert_allclose(ratios[brmas.get_loc(b)], median) + assert (ratios >= 1).all() + + +def test_scottish_percentile_ratios_use_scotlands_own_lists(): + """Ratios from Rent Service Scotland's April 2020 lists (FOI 202200303624).""" + from policyengine_uk.utils.lha import _percentile_ratios + + ratios = _percentile_ratios(0.5) + brmas = published_rates().brmas + assert ratios[brmas.get_loc("GREATER_GLASGOW"), 3] == pytest.approx(1.267, abs=5e-4) + assert ratios[brmas.get_loc("LOTHIAN"), 2] == pytest.approx(1.091, abs=5e-4) + + +def test_scottish_lists_are_rent_service_scotlands_own(): + """Scotland's lists (FOI 202200303624) reproduce the published 30th + percentiles and copy no English list, unlike the rows they replaced.""" + from policyengine_uk.utils.lha import ( + LIST_OF_RENTS_PATH, + NON_ENGLISH_REGIONS, + round_half_up, + statutory_percentile, + ) + + rents = pd.read_csv(LIST_OF_RENTS_PATH) + published = pd.read_csv(PUBLISHED_RATES_PATH).set_index( + ["year", "brma", "lha_category"] + ) + blocks = { + key: tuple(np.sort(group.weekly_rent.to_numpy())) + for key, group in rents.groupby(["region", "year", "brma", "lha_category"]) + } + english = { + rents + for (region, *_), rents in blocks.items() + if region not in NON_ENGLISH_REGIONS + } + scottish = {k: v for k, v in blocks.items() if k[0] == "SCOTLAND"} + assert len(scottish) == 2 * 18 * 5 + assert not english & set(scottish.values()) + exact = sum( + float(round_half_up(statutory_percentile(np.array(v), 0.3))) + == pytest.approx(published.percentile_30[(year, brma, category)], abs=0.001) + for (_, year, brma, category), v in scottish.items() + ) + # 87 of 90 cells for April 2019 and 86 of 90 for April 2020 to the penny. + assert exact >= 170 + + +def _monthly(year: int, brma: str, category: str, reform=None) -> float: + situation = { + "people": {"person": {"age": {year: 35}}}, + "benunits": { + "benunit": { + "members": ["person"], + "LHA_category": {year: category}, + "benunit_rent": {year: 1_000_000}, + } + }, + "households": {"household": {"members": ["person"], "brma": {year: brma}}}, + } + annual = SYSTEM_SIMULATION(situation, reform).calculate("uc_LHA_cap", year) + return float(annual[0]) / 12 + + +def test_northern_ireland_uc_keeps_the_march_2020_minimum(): + """SR 2016/222 Sch 1 para 6 floors NI's monthly rates too. + + NI's monthly rates were not published before April 2024, so the floor is + the April 2020 weekly rate (Belfast shared room, GBP 53.58) converted to + a month: GBP 232.82. + """ + reform = {"gov.dwp.LHA.maximum_monthly.A": {"2024": 100}} + assert _monthly(2024, "BELFAST", "A", reform) == pytest.approx(232.82, abs=0.001) + + +def test_a_cap_binds_through_a_restated_held_rate(): + """A reform capping the April 2020 rate holds through Wales's restatement. + + Blaenau Gwent one bedroom: published at GBP 66.39 (April 2020) and GBP + 66.74 (April 2022). With a GBP 60 maximum in 2020 the held 2022 rate is + GBP 60, not GBP 60 plus the 35p restatement. + """ + assert _weekly(2022, "BLAENAU_GWENT", "B") == pytest.approx(66.74, abs=0.001) + capped = _weekly( + 2022, "BLAENAU_GWENT", "B", reform={"gov.dwp.LHA.maximum.B": {"2020": 60}} + ) + assert capped == pytest.approx(60.0, abs=0.001) + + +@pytest.mark.parametrize( + "parameter,value,expected,universal_credit", + [ + ("gov.dwp.LHA.maximum.B", 320.005, 320.01, False), + ("gov.dwp.LHA.maximum_monthly.B", 1400.005, 1400.01, True), + ], +) +def test_a_capped_rate_is_rounded_to_the_penny( + parameter, value, expected, universal_credit +): + """Sch 3B para 2(10): a half-penny maximum rounds up.""" + reform = {parameter: {"2024": value}} + measure = _monthly if universal_credit else _weekly + assert measure(2024, "CENTRAL_LONDON", "B", reform) == pytest.approx( + expected, abs=0.001 + ) + + +@pytest.mark.parametrize("year", [2019, 2024, 2026]) +def test_housing_benefit_reads_the_published_rate_for_its_own_category(year): + """HB and UC read one table, each through its own category. + + Every BRMA and category, with the Universal Credit category set to a + different one: ``housing_benefit_LHA_rate`` follows + ``housing_benefit_LHA_category`` and ``BRMA_LHA_rate`` follows + ``LHA_category``, each matching the determined table (and so every + published cell). + """ + from policyengine_uk import Simulation + + table = lha_rates(SYSTEM.parameters, year, universal_credit=False) + cells = [ + (brma, i, j) + for i, brma in enumerate(table["brmas"]) + for j in range(len(CATEGORIES)) + ] + situation = {"people": {}, "benunits": {}, "households": {}} + for n, (brma, _, j) in enumerate(cells): + situation["people"][f"p{n}"] = {"age": {year: 40}} + situation["benunits"][f"b{n}"] = { + "members": [f"p{n}"], + "housing_benefit_LHA_category": {year: CATEGORIES[j]}, + "LHA_category": {year: CATEGORIES[(j + 2) % len(CATEGORIES)]}, + } + situation["households"][f"h{n}"] = { + "members": [f"p{n}"], + "brma": {year: brma}, + } + simulation = Simulation(situation=situation) + housing_benefit = simulation.calculate("housing_benefit_LHA_rate", year) / 52 + universal_credit = simulation.calculate("BRMA_LHA_rate", year) / 52 + rows = [i for _, i, _ in cells] + own = table["rate"][rows, [j for _, _, j in cells]] + swapped = table["rate"][rows, [(j + 2) % len(CATEGORIES) for _, _, j in cells]] + np.testing.assert_allclose(housing_benefit, own, atol=0.001) + np.testing.assert_allclose(universal_credit, swapped, atol=0.001) + published = published_rates().at("rate", year)[rows, [j for _, _, j in cells]] + have = ~np.isnan(published) + assert have.sum() > 0 + np.testing.assert_allclose(housing_benefit[have], published[have], atol=0.001) diff --git a/policyengine_uk/tests/test_lha_uc_maximum.py b/policyengine_uk/tests/test_lha_uc_maximum.py index 609018beff..1669e88518 100644 --- a/policyengine_uk/tests/test_lha_uc_maximum.py +++ b/policyengine_uk/tests/test_lha_uc_maximum.py @@ -100,16 +100,16 @@ def test_housing_benefit_keeps_the_weekly_maximum(): def test_the_monthly_maximum_does_not_apply_before_it_existed(): """The monthly series starts in April 2020. - Parameters are backdated to 2015 on load, so without an explicit gate the - 2020 maximum would apply to earlier years, where it is far above the - figures actually in force. Before the series starts, Universal Credit - falls back to the weekly Housing Benefit rate. + Parameters are backdated to 2015 on load, so the 2020 maximum must not + reach earlier years, where it is far above the figures actually in force. + Before April 2020, Universal Credit uses the published monthly rate. """ simulation = _simulation(50_000, "E", year=2019) housing = simulation.calculate("uc_housing_costs_element", 2019)[0] - weekly_rate = simulation.calculate("BRMA_LHA_rate", 2019)[0] - assert float(housing) == pytest.approx(float(weekly_rate), abs=0.01) + # DWP, Universal Credit LHA rates 2014 to 2019 (England, April 2019): + # central London four bedrooms, GBP 1,917.12 a month. + assert float(housing) == pytest.approx(1_917.12 * 12, abs=0.01) assert float(housing) < 2_579.98 * 12 diff --git a/policyengine_uk/utils/build_lha_published_rates.py b/policyengine_uk/utils/build_lha_published_rates.py new file mode 100644 index 0000000000..46ace487e0 --- /dev/null +++ b/policyengine_uk/utils/build_lha_published_rates.py @@ -0,0 +1,621 @@ +"""Build ``lha_published_rates.csv.gz`` from the published LHA tables. + +Every source file is listed, with its URL and SHA-256, in +``lha_published_rates_sources.csv`` next to the output. The build downloads each +file (or reads it from a cache), checks its hash, parses it, and writes one row +per determination year, Broad Rental Market Area and LHA category: + +- ``rate``: the weekly Housing Benefit LHA in force from April of ``year``; +- ``percentile_30``: the weekly rent at the 30th percentile of the list of + rents for the twelve months to the previous September. The VOA publishes it + for England every year, including frozen ones. Where a publisher does not, it + is filled for the years rates were reset to the 30th percentile with the + published rate, which reproduces the determination; +- ``uc_rate``: the monthly Universal Credit LHA in force from April of ``year``; +- ``uc_percentile_30``: the monthly percentile rent, filled in reset years from + ``uc_rate`` wherever the Housing Benefit rate equals its percentile rent, so + no maximum or anomalous-rate adjustment moved it. + +Spreadsheet readers are not runtime dependencies, so run the build with them: + + uv run --with openpyxl --with xlrd --with odfpy --with beautifulsoup4 \ + python scripts/build_lha_published_rates.py --cache ~/.cache/lha + +gov.scot and the NIHE site can refuse scripted downloads; the NIHE pages are +read from the Internet Archive's original-bytes (``id_``) captures. A file that +will not download can be placed in the cache by hand under its ``file`` name, +and the build still checks its hash. +""" + +from __future__ import annotations + +import argparse +import hashlib +import re +import zipfile +import xml.etree.ElementTree as ElementTree +from html.parser import HTMLParser +from pathlib import Path +from urllib.request import Request, urlopen + +import numpy as np +import pandas as pd + +from policyengine_uk.utils.lha import ( + CATEGORIES, + LHA_DIRECTORY, + PUBLISHED_RATES_PATH, + RESET_YEARS, + round_half_up, +) +from policyengine_uk.variables.household.demographic.locations import BRMAName + +SOURCES_PATH = LHA_DIRECTORY / "lha_published_rates_sources.csv" + +USER_AGENT = "Mozilla/5.0 (compatible; policyengine-uk LHA build)" + +# Published BRMA spellings that differ from the enum labels. +ALIASES = { + "flint": "FLINTSHIRE", + "renfrewshire inverclyde": "RENFREWSHIRE_AND_INVERCLYDE", + "weston super mare": "WESTON_S_MARE", +} + + +def normalise(name: str) -> str: + name = name.lower().replace("&", " and ").replace("-", " ").replace("'", "") + name = re.sub(r"[^a-z0-9 ]", " ", name) + return re.sub(r"\s+", " ", name).strip() + + +_ENUM_BY_NAME = {} +for member in BRMAName: + _ENUM_BY_NAME.setdefault(normalise(member.value), member.name) + _ENUM_BY_NAME.setdefault(normalise(member.name.replace("_", " ")), member.name) + + +def brma_enum(published: str) -> str: + key = normalise(published) + if key in ALIASES: + return ALIASES[key] + if key not in _ENUM_BY_NAME: + raise KeyError(f"Unrecognised BRMA name {published!r}") + return _ENUM_BY_NAME[key] + + +def money(value) -> float | None: + if isinstance(value, str): + value = value.replace("£", "").replace(",", "").strip() + try: + number = float(value) + except (TypeError, ValueError): + return None + return None if np.isnan(number) else number + + +def fetch(source: pd.Series, cache: Path) -> Path: + path = cache / source.file + if not path.exists(): + request = Request(source.url, headers={"User-Agent": USER_AGENT}) + with urlopen(request) as response: + path.write_bytes(response.read()) + digest = hashlib.sha256(path.read_bytes()).hexdigest() + if digest != source.sha256: + raise ValueError(f"{source.id}: {path.name} has SHA-256 {digest}") + return path + + +def brma_block(frame: pd.DataFrame) -> list[tuple[str, list[float]]]: + """Rows of (BRMA, five category values) from a BRMA-by-category table. + + The BRMA is the first text cell of a row, followed by the five weekly or + monthly category columns; monthly-equivalent columns further right are + ignored. Header, title and note rows have no five numbers and are skipped. + """ + rows = [] + for cells in frame.itertuples(index=False): + cells = list(cells) + first = next( + (i for i, c in enumerate(cells) if isinstance(c, str) and c.strip()), + None, + ) + if first is None: + continue + values = [money(c) for c in cells[first + 1 : first + 7]] + # Some Welsh tables put a five-digit BRMA code before the rates. + code = values[0] + if code is not None and code == int(code) and 10_000 <= code < 100_000: + values = values[1:] + values = values[:5] + if len(values) == 5 and all(v is not None for v in values): + rows.append((cells[first].strip(), values)) + return rows + + +def parse_voa(path: Path, source: pd.Series) -> list[dict]: + """VOA Housing Benefit tables: the year's rates and percentile rents.""" + out = [] + for part in source.location.split(";"): + measure, sheet = part.split("=") + frame = pd.read_excel(path, sheet_name=sheet, header=None) + title = " ".join(str(c) for c in frame.iloc[:2].to_numpy().ravel()) + expected = ( + "30th Percentile" if measure == "percentile_30" else f"April {source.year}" + ) + if expected.lower() not in title.lower(): + raise ValueError(f"{source.id} {sheet}: header {title!r}") + for name, values in brma_block(frame): + out += [ + dict( + year=int(source.year), + brma=brma_enum(name), + lha_category=c, + measure=measure, + value=v, + ) + for c, v in zip(CATEGORIES, values) + ] + return out + + +def parse_dwp_uc(path: Path, source: pd.Series) -> list[dict]: + """DWP monthly Universal Credit rates, one table or one sheet per year.""" + if path.suffix == ".csv": + tables = { + int(source.year): pd.read_csv( + path, header=None, dtype=str, encoding="latin-1" + ) + } + else: + book = pd.ExcelFile(path, engine="odf") + sheets = [s for s in book.sheet_names if not s.lower().startswith("expl")] + if str(source.year).isdigit(): + if len(sheets) != 1: + raise ValueError(f"{source.id}: sheets {book.sheet_names}") + tables = {int(source.year): pd.read_excel(book, sheets[0], header=None)} + else: + tables = { + int(re.search(r"\d{4}", s).group()): pd.read_excel(book, s, header=None) + for s in sheets + } + out = [] + for year, frame in tables.items(): + for name, values in brma_block(frame): + out += [ + dict( + year=year, + brma=brma_enum(name), + lha_category=c, + measure="uc_rate", + value=v, + ) + for c, v in zip(CATEGORIES, values) + ] + return out + + +ODS_TABLE = "{urn:oasis:names:tc:opendocument:xmlns:table:1.0}" +ODS_OFFICE = "{urn:oasis:names:tc:opendocument:xmlns:office:1.0}" + + +def read_ods(path: Path) -> dict[str, list[list[str]]]: + """Sheets of an ODS file as lists of rows of cell strings. + + A small reader for the Welsh tables, whose long runs of repeated empty + cells stall pandas' odf engine. Numeric cells give their stored value. + """ + root = ElementTree.fromstring(zipfile.ZipFile(path).read("content.xml")) + sheets = {} + for table in root.iter(ODS_TABLE + "table"): + rows = [] + for row in table.iter(ODS_TABLE + "table-row"): + cells = [] + for cell in row: + if cell.tag not in ( + ODS_TABLE + "table-cell", + ODS_TABLE + "covered-table-cell", + ): + continue + repeat = min( + int(cell.get(ODS_TABLE + "number-columns-repeated", "1")), 64 + ) + value = cell.get(ODS_OFFICE + "value") + text = value if value is not None else "".join(cell.itertext()).strip() + cells += [text] * repeat + while cells and not cells[-1]: + cells.pop() + if cells: + rows.append(cells) + sheets[table.get(ODS_TABLE + "name")] = rows + return sheets + + +WELSH_CATEGORIES = { + "shared accommodation": "A", + "1 bedroom": "B", + "2 bedroom": "C", + "3 bedroom": "D", + "4 bedroom": "E", +} + + +def parse_rent_officers_wales(path: Path, source: pd.Series) -> list[dict]: + """Rent Officers Wales weekly tables. + + Each BRMA has a header row (its code and name) followed by one row per + category. The rate column is headed "New LHA rates for Apr " and the + percentile column "New 30th percentile from list of rents". Cross-border + BRMAs that the VOA determines (West Cheshire) are left to the VOA tables. + """ + sheets = read_ods(path) + rows = next(rows for name, rows in sheets.items() if "weekly" in name.lower()) + title = " ".join(" ".join(r) for r in rows[:2]) + if f"april {source.year}" not in title.lower(): + raise ValueError(f"{source.id}: title {title!r}") + labels: dict[int, str] = {} + body_start = None + for index, row in enumerate(rows): + if any(normalise(c) in WELSH_CATEGORIES for c in row): + body_start = index - 1 + break + for column, cell in enumerate(row): + labels[column] = f"{labels.get(column, '')} {cell}".strip() + columns = {} + for column, label in labels.items(): + text = label.lower() + if "new lha rates" in text: + columns["rate"] = column + elif "30th percentile" in text: + columns["percentile_30"] = column + if "rate" not in columns: + raise ValueError(f"{source.id}: no rate column in {labels}") + out, brma = [], None + for row in rows[body_start:]: + text = [c for c in row if c] + if not text: + continue + label = normalise(text[0]) + if label in WELSH_CATEGORIES: + if brma is None: + continue + for measure, column in columns.items(): + value = money(row[column]) if column < len(row) else None + if value is not None: + out.append( + dict( + year=int(source.year), + brma=brma, + lha_category=WELSH_CATEGORIES[label], + measure=measure, + value=value, + ) + ) + continue + # The BRMA's code and name, sometimes in one cell; the April 2022 table + # has a stray figure in the South Gwynedd header row. + name = " ".join(c for c in text if money(c) is None) + name = re.sub(r"^\d+\s+", "", name).strip() + brma = None if normalise(name) == "west cheshire" else brma_enum(name) + return out + + +class _HtmlTables(HTMLParser): + """Visible text of every table cell in an HTML page.""" + + def __init__(self): + super().__init__(convert_charrefs=True) + self.tables, self._table, self._row, self._cell = [], None, None, None + + def handle_starttag(self, tag, attrs): + if tag == "table": + self._table = [] + elif tag == "tr" and self._table is not None: + self._row = [] + elif tag in ("td", "th") and self._row is not None: + self._cell = [] + elif tag == "br" and self._cell is not None: + self._cell.append(" ") + + def handle_endtag(self, tag): + if tag in ("td", "th") and self._cell is not None: + self._row.append(" ".join("".join(self._cell).split())) + self._cell = None + elif tag == "tr" and self._row is not None: + self._table.append(self._row) + self._row = None + elif tag == "table" and self._table is not None: + self.tables.append(self._table) + self._table = None + + def handle_data(self, data): + if self._cell is not None: + self._cell.append(data) + + +def html_tables(path: Path) -> list[list[list[str]]]: + parser = _HtmlTables() + parser.feed(path.read_text(encoding="utf-8", errors="replace")) + return parser.tables + + +SCOTTISH_CATEGORIES = { + "1 bedroom shared": "A", + "1 bedroom": "B", + "2 bedroom": "C", + "3 bedroom": "D", + "4 bedroom": "E", +} + + +def scottish_brma(name: str) -> str: + # Spelling variants on gov.scot: council lists in brackets, "Highlands", + # "Renfrewshire/ Inverclyde", stray emphasis markers and a curly quote. + name = name.split("(", 1)[0].replace("**", "").replace("”", " ") + name = name.replace("/", " and ").replace("Highlands", "Highland") + return brma_enum(name) + + +def parse_gov_scot(path: Path, source: pd.Series) -> list[dict]: + """Scottish Government (Rent Service Scotland) annual LHA pages. + + The first table is the headline weekly rates by BRMA (categories A to E + across). The second is the methodology table: a row per BRMA, then a row + per category giving the earlier rate (some years), the 30th percentile of + the list of rents, and the rate determined. + """ + tables = html_tables(path) + out = [] + for part in source.location.split(";"): + measure, index = part.split("=") + table = tables[int(index)] + if measure == "rate": + for row in table: + values = [money(c) for c in row[1:6]] if len(row) >= 6 else [] + if row and row[0] and len(values) == 5 and None not in values: + out += [ + dict( + year=int(source.year), + brma=scottish_brma(row[0]), + lha_category=c, + measure="rate", + value=v, + ) + for c, v in zip(CATEGORIES, values) + ] + else: + brma = None + for row in table: + if not row or not row[0]: + continue + label = row[0].strip().lower() + if label in SCOTTISH_CATEGORIES: + values = [money(c) for c in row[1:] if c] + out.append( + dict( + year=int(source.year), + brma=brma, + lha_category=SCOTTISH_CATEGORIES[label], + measure="percentile_30", + value=values[1], + ) + ) + elif label != "brma": + brma = scottish_brma(row[0]) + return out + + +NIHE_BRMAS = { + "Belfast": "BELFAST", + "Lough Neagh Lower": "LOUGH_NEAGH_LOWER", + "Lough Neagh Upper": "LOUGH_NEAGH_UPPER", + "North": "NORTH_NI", + "North West": "NORTH_WEST_NI", + "South East": "SOUTH_EAST_NI", + "South": "SOUTH_NI", + "South West": "SOUTH_WEST_NI", +} +NI_MONEY = re.compile(r"£\s*([0-9]+(?:,[0-9]{3})*\.[0-9]{2})(?![0-9])") + + +def _text(node) -> str: + return " ".join(node.get_text(" ", strip=True).split()) + + +def _nihe_brma(name: str) -> str: + name = re.sub(r"^BRMA\s+[0-9]+\s+", "", name) + name = re.sub(r"\s+BRMA(?:\s+\(.*\))?$", "", name) + return NIHE_BRMAS[name] + + +def _nihe_category(label: str) -> str: + label = " ".join(label.split()).lower() + if label.startswith(("single room", "shared room", "shared accommodation")): + return "A" + match = re.match(r"([1-4])\s*-?\s*bed", label) + if match: + return "BCDE"[int(match[1]) - 1] + raise ValueError(f"Unrecognised category {label!r}") + + +def _amount(text: str) -> float: + figures = NI_MONEY.findall(text) + if len(figures) != 1: + raise ValueError(f"Expected one pound figure in {text!r}") + return float(figures[0].replace(",", "")) + + +def parse_nihe(path: Path, source: pd.Series) -> list[dict]: + """Northern Ireland Housing Executive pages and nidirect's UC table. + + NIHE has published its weekly rates in three layouts: a table with a + column per BRMA (2015 to 2018), and collapsible panels per BRMA (2019 on). + Its calculation pages give the 30th percentile in a table per BRMA (2015 + to 2017) or in panels (2018). nidirect publishes Northern Ireland's + monthly Universal Credit rates as a BRMA by category table. + """ + from bs4 import BeautifulSoup + + data = path.read_bytes() + try: + html = data.decode("utf-8") + except UnicodeDecodeError: + # Old NIHE pages declare UTF-8 but contain CP1252 bytes. + html = data.decode("cp1252") + soup = BeautifulSoup(html, "html.parser") + year, mode = int(source.year), source.location + cells = [] + if mode == "hb_transposed": + (table,) = [ + t + for t in soup.find_all("table") + if "Broad Rental Market Area" in t.get_text() + ] + rows = table.find_all("tr") + names = [_text(c) for c in rows[0].find_all(["th", "td"], recursive=False)][1:] + for row in rows[1:]: + row_cells = row.find_all(["th", "td"], recursive=False) + if row_cells and "rate per week" in _text(row_cells[0]).lower(): + category = _nihe_category(_text(row_cells[0])) + for name, cell in zip(names, row_cells[1:]): + cells.append((name, category, "rate", _amount(_text(cell)))) + elif mode == "hb_accordion": + for panel in soup.select(".collapsible_panel"): + heading = panel.select_one("h3.collapsible_panel-title") + if heading is None: + continue + for item in panel.select(".collapsible_panel-content li"): + text = _text(item) + cells.append( + (_text(heading), _nihe_category(text), "rate", _amount(text)) + ) + elif mode == "percentile_tables": + for table in soup.find_all("table"): + rows = table.find_all("tr") + headers = [ + _text(c) for c in rows[0].find_all(["th", "td"], recursive=False) + ] + columns = [ + i + for i, h in enumerate(headers) + if f"30th percentile rate for {year}" in h + ] + if not columns: + continue + heading = next( + h + for h in table.find_all_previous(["h2", "h3", "h4"]) + if h.find("table") is None and re.match(r"BRMA\s+[0-9]+\s+", _text(h)) + ) + for row in rows[1:]: + row_cells = row.find_all(["th", "td"], recursive=False) + cells.append( + ( + _text(heading), + _nihe_category(_text(row_cells[0])), + "percentile_30", + _amount(_text(row_cells[columns[0]])), + ) + ) + elif mode == "percentile_accordion": + for panel in soup.select(".collapsible_panel"): + name = _text(panel.select_one("h3.collapsible_panel-title")) + for paragraph in panel.select_one(".collapsible_panel-content").find_all( + "p" + ): + text = _text(paragraph) + if f"30th percentile rate for {year}:" in text: + label = _text(paragraph.find_previous("h3")) + cells.append( + (name, _nihe_category(label), "percentile_30", _amount(text)) + ) + elif mode == "uc_table": + (table,) = [ + t + for t in soup.find_all("table") + if "Shared Room Rate" in t.get_text() and "Belfast BRMA" in t.get_text() + ] + for row in table.find_all("tr")[1:]: + row_cells = row.find_all(["th", "td"], recursive=False) + for category, cell in zip(CATEGORIES, row_cells[1:]): + cells.append( + (_text(row_cells[0]), category, "uc_rate", _amount(_text(cell))) + ) + else: + raise ValueError(f"{source.id}: unknown NIHE layout {mode}") + if len(cells) != 40: + raise ValueError(f"{source.id}: {len(cells)} cells, expected 40") + return [ + dict( + year=year, + brma=_nihe_brma(name), + lha_category=category, + measure=measure, + value=value, + ) + for name, category, measure, value in cells + ] + + +PARSERS = { + "voa": parse_voa, + "dwp_uc": parse_dwp_uc, + "rent_officers_wales": parse_rent_officers_wales, + "gov_scot": parse_gov_scot, + "nihe": parse_nihe, +} + + +def build(cache: Path) -> tuple[pd.DataFrame, pd.DataFrame]: + cache.mkdir(parents=True, exist_ok=True) + sources = pd.read_csv(SOURCES_PATH, dtype=str) + records = [] + for source in sources.itertuples(index=False): + rows = PARSERS[source.parser](fetch(source, cache), source) + records += [dict(row, source=source.id) for row in rows] + long = pd.DataFrame(records) + # A few early tables print unrounded figures; rates are whole pence + # (Sch 3B para 2(10)). + long["value"] = round_half_up(long.value) + + duplicated = long.duplicated( + ["year", "brma", "lha_category", "measure"], keep=False + ) + if duplicated.any(): + raise ValueError(f"Duplicate cells:\n{long[duplicated].head(20)}") + + table = long.pivot_table( + index=["year", "brma", "lha_category"], columns="measure", values="value" + ).reset_index() + for measure in ("rate", "percentile_30", "uc_rate"): + if measure not in table: + table[measure] = np.nan + + reset = table.year.isin(RESET_YEARS) + table["percentile_30"] = table.percentile_30.where( + table.percentile_30.notna() | ~reset, table.rate + ) + unadjusted = (table.rate - table.percentile_30).abs() < 0.005 + table["uc_percentile_30"] = table.uc_rate.where(reset & unadjusted) + table = table[ + [ + "year", + "brma", + "lha_category", + "rate", + "percentile_30", + "uc_rate", + "uc_percentile_30", + ] + ].sort_values(["year", "brma", "lha_category"]) + return table, long + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--cache", type=Path, default=Path.home() / ".cache" / "lha") + parser.add_argument("--output", type=Path, default=PUBLISHED_RATES_PATH) + args = parser.parse_args(argv) + table, _ = build(args.cache) + table.to_csv(args.output, index=False, float_format="%.2f") + print(f"Wrote {len(table)} rows to {args.output}") + return 0 diff --git a/policyengine_uk/utils/build_scottish_list_of_rents.py b/policyengine_uk/utils/build_scottish_list_of_rents.py new file mode 100644 index 0000000000..de9585b3af --- /dev/null +++ b/policyengine_uk/utils/build_scottish_list_of_rents.py @@ -0,0 +1,96 @@ +"""Put Scotland's own lists of rents into ``lha_list_of_rents.csv.gz``. + +The Scottish rows that came with the file were copies of English BRMAs' lists. +The Scottish Government released Rent Service Scotland's market evidence, one +row per rent, under FOI 202200303624. Each worksheet holds the rents collected +in the twelve months to September of its year, which set the next April's +determination, so sheet ``Y - 1`` is the list for April ``Y``. This replaces all +Scottish rows with sheets 2018 and 2019 as the lists for 2019 and 2020, keeping +only each rent, its BRMA and its LHA category, and leaves every other row as it +was. + +Spreadsheet readers are not runtime dependencies, so run the build with them: + + uv run --with openpyxl python scripts/build_scottish_list_of_rents.py \ + --cache ~/.cache/lha +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import pandas as pd + +from policyengine_uk.utils.build_lha_published_rates import fetch, scottish_brma +from policyengine_uk.utils.lha import CATEGORIES, LIST_OF_RENTS_PATH + +SOURCE = pd.Series( + { + "id": "scot-foi-202200303624-brma-2016-2021", + "file": "FOI-202200303624-Information-Released-BRMA-2016-2021.xlsx", + "url": ( + "https://www.gov.scot/binaries/content/documents/govscot/publications/" + "foi-eir-release/2023/08/foi-202200303624/documents/" + "foi-202200303624---information-released---brma-2016-2021/" + "foi-202200303624---information-released---brma-2016-2021/" + "govscot%3Adocument/FOI%2B202200303624%2B-%2BInformation%2BReleased" + "%2B-%2BBRMA%2B2016-2021.xlsx?download=true" + ), + "landing_page": "https://www.gov.scot/publications/foi-202200303624/", + "sha256": "dba86fa1cce920c8f646460bb13e1d99e16927d57fdf290e874f9631e804ed06", + } +) +YEARS = (2019, 2020) # April determinations; sheet = year - 1. + + +def scottish_lists(path: Path) -> pd.DataFrame: + frames = [] + for year in YEARS: + sheet = pd.read_excel(path, sheet_name=str(year - 1)).dropna(how="all") + # Sheet 2019 ends with two rents that name no BRMA; they cannot be placed. + assert sheet["BRMA"].isna().sum() == (2 if year == 2020 else 0) + sheet = sheet[sheet["BRMA"].notna()] + assert (sheet["FREQUENCY"] == "Weekly").all() + assert ( + sheet["YEAR ENDING SEPTEMBER"] == f"{year - 2}/{str(year - 1)[2:]}" + ).all() + category = sheet["LHA TYPE"].str.strip().str.removeprefix("Cat ") + assert category.isin(CATEGORIES).all() + frames.append( + pd.DataFrame( + { + # Rents are whole pennies; rounding only guards the float. + "weekly_rent": sheet["NET RENT"].astype(float).round(2), + "year": year, + "brma": sheet["BRMA"].map(scottish_brma), + "lha_category": category, + "region": "SCOTLAND", + } + ) + ) + lists = pd.concat(frames, ignore_index=True) + return lists.sort_values(["year", "brma", "lha_category", "weekly_rent"]) + + +def build(cache: Path) -> pd.DataFrame: + cache.mkdir(parents=True, exist_ok=True) + rents = pd.read_csv(LIST_OF_RENTS_PATH) + scotland = scottish_lists(fetch(SOURCE, cache)) + assert scotland.brma.nunique() == 18 + rents = pd.concat([rents[rents.region != "SCOTLAND"], scotland], ignore_index=True) + return rents + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cache", type=Path, default=Path.home() / ".cache" / "lha") + args = parser.parse_args(argv) + rents = build(args.cache) + rents.to_csv( + LIST_OF_RENTS_PATH, + index=False, + compression={"method": "gzip", "mtime": 0}, + ) + print(f"Wrote {len(rents):,} rents to {LIST_OF_RENTS_PATH}") + return 0 diff --git a/policyengine_uk/utils/lha.py b/policyengine_uk/utils/lha.py new file mode 100644 index 0000000000..066e24121e --- /dev/null +++ b/policyengine_uk/utils/lha.py @@ -0,0 +1,437 @@ +"""Local Housing Allowance determinations. + +Rent officers determine LHA rates each January for every Broad Rental Market +Area (BRMA) and category of dwelling, and the rates take effect the following +April: the Valuation Office Agency in England, Rent Officers Wales, Rent Service +Scotland, and the Northern Ireland Housing Executive. The model reads the +published determinations from ``lha_published_rates.csv.gz`` and re-applies the +rules of Schedule 3B to the Rent Officers (Housing Benefit Functions) Order +1997, and Schedule 1 to the Rent Officers (Universal Credit Functions) Order +2013, so that freeze, percentile and maximum reforms move rates the way a +determination would: + +1. the rent at the 30th percentile of the BRMA's list of rents for the twelve + months to the previous September (Sch 3B para 2(4) to (8)); +2. the lower of that rent and the national maximum (para 2(2)), rounded to + the nearest penny, halves up (para 2(10)); +3. the anomalous-rate rule: a category is raised to the highest rate of any + smaller category (para 3); +4. from April 2024, the minimum: no rate below the one determined on 31 March + 2020 (para 3A, inserted by SI 2024/11). + +While ``gov.dwp.LHA.freeze`` is true, the rates are those of the last year in +which it was false (the Modification Orders substitute the earlier +determination for para 2(2)). + +Determinations before April 2020 followed rules this model does not encode +(CPI and 1% uprating to 2015, the 2016-2020 freeze with targeted affordability +uplifts), so for those years the published rate itself is the determination. +""" + +from functools import lru_cache +from pathlib import Path + +import numpy as np +import pandas as pd + +CATEGORIES = ("A", "B", "C", "D", "E") + +# The first determination the model recomputes from its rules rather than +# reading as published: the April 2020 reset to the 30th percentile. +FIRST_RULES_YEAR = 2020 + +# The percentile the published percentile rents are taken at. +PUBLISHED_PERCENTILE = 0.3 + +# Determinations that reset rates to the 30th percentile, and the published +# tables that held them in cash terms, under the Social Security (Coronavirus) +# (Further Measures) Regulations 2020 reg 4, the Rent Officers (Housing Benefit +# and Universal Credit Functions) (Amendment) Order 2024, the Modification +# Orders SI 2020/1519, 2021/1380, 2023/6, 2025/5 and 2026/5, and their Northern +# Ireland equivalents. +RESET_YEARS = (2020, 2024) +HELD_TABLES = {2021: 2020, 2022: 2020, 2023: 2020, 2025: 2024, 2026: 2024} + +# The UC Order converts a rent to a monthly one through the rent for a year, +# divided by 12 (Sch 1 para 3(8)); Sch 3B para 2(5)(c) converts the other way, +# through the rent for a year divided by 365 and multiplied by 7. A weekly +# rent's year is therefore 365/7 weeks. DWP's published monthly rates for the +# April 2020 and April 2024 determinations in England are the weekly 30th +# percentile times this factor, to within 3p, which is the rounding of the +# weekly figure. The model uses the factor only where no monthly rate is +# published. +WEEKLY_TO_MONTHLY = 365 / 7 / 12 + +LHA_DIRECTORY = ( + Path(__file__).resolve().parents[1] / "parameters" / "gov" / "dwp" / "LHA" +) +PUBLISHED_RATES_PATH = LHA_DIRECTORY / "lha_published_rates.csv.gz" +LIST_OF_RENTS_PATH = LHA_DIRECTORY / "lha_list_of_rents.csv.gz" + +MEASURES = ("rate", "percentile_30", "uc_rate", "uc_percentile_30") + + +def round_half_up(values: np.ndarray) -> np.ndarray: + """Round pounds to the nearest penny, halves up (Sch 3B para 2(10)). + + np.round is half-even. Pence are snapped to 6dp first, because an exact + half such as 298.835 is held as 29883.499999999996 once scaled. + """ + values = np.asarray(values, dtype=float) + return np.floor(np.round(values * 100, 6) + 0.5) / 100 + + +class PublishedRates: + """The published LHA tables as [year, BRMA, category] arrays.""" + + def __init__(self, table: pd.DataFrame): + self.years = np.array(sorted(table.year.unique()), dtype=int) + self.brmas = pd.Index(sorted(table.brma.unique())) + shape = (len(self.years), len(self.brmas), len(CATEGORIES)) + year_index = np.searchsorted(self.years, table.year.to_numpy()) + brma_index = self.brmas.get_indexer(table.brma) + category_index = pd.Index(CATEGORIES).get_indexer(table.lha_category) + if (category_index < 0).any(): + raise ValueError("Unknown LHA category in the published rates") + for measure in MEASURES: + values = np.full(shape, np.nan) + values[year_index, brma_index, category_index] = table[measure].to_numpy( + dtype=float + ) + setattr(self, measure, values) + + def latest(self, measure: str, year: int) -> tuple[np.ndarray, np.ndarray]: + """The latest published value at or before ``year`` for each cell. + + Returns the values and the year each was published for, as + [BRMA, category] arrays. Where nothing is published by ``year``, the + earliest published value is used and its year returned. + """ + values = getattr(self, measure) + available = ~np.isnan(values) + eligible = available & (self.years <= year)[:, None, None] + reversed_positions = np.argmax(eligible[::-1], axis=0) + latest = len(self.years) - 1 - reversed_positions + earliest = np.argmax(available, axis=0) + position = np.where(eligible.any(axis=0), latest, earliest) + chosen = np.take_along_axis(values, position[None], axis=0)[0] + return chosen, self.years[position] + + def at(self, measure: str, year: int) -> np.ndarray: + """Values published for exactly ``year`` (NaN where none).""" + values = getattr(self, measure) + matches = np.flatnonzero(self.years == year) + if len(matches) == 0: + return np.full(values.shape[1:], np.nan) + return values[matches[0]] + + +@lru_cache(maxsize=1) +def published_rates() -> PublishedRates: + return PublishedRates(pd.read_csv(PUBLISHED_RATES_PATH)) + + +NON_ENGLISH_REGIONS = ("WALES", "SCOTLAND", "NORTHERN_IRELAND") +# The Welsh and Northern Irish lists in the file are copies of English BRMAs' +# lists, often of another category, so they do not describe their own areas. +# The Scottish lists are Rent Service Scotland's own (FOI 202200303624; see +# ``utils/build_scottish_list_of_rents.py``). +COPIED_LIST_REGIONS = ("WALES", "NORTHERN_IRELAND") + + +@lru_cache(maxsize=1) +def _sorted_list_of_rents() -> tuple[dict, dict]: + """Each region's latest list of rents, sorted, keyed by (BRMA, category). + + Returns the lists and each BRMA's region. + """ + rents = pd.read_csv(LIST_OF_RENTS_PATH) + rents = rents[rents.year == rents.groupby("region").year.transform("max")] + lists = { + key: np.sort(group.weekly_rent.to_numpy(dtype=float)) + for key, group in rents.groupby(["brma", "lha_category"]) + } + regions = rents.groupby("brma").region.first().to_dict() + return lists, regions + + +def statutory_percentile(rents: np.ndarray, percentile: float) -> float: + """The rent at ``percentile`` of an ascending list (Sch 3B para 2(8)). + + Where the list length times the percentile is a whole number P, the rent + is the mean of the rents at positions P and P + 1; otherwise it is the + rent at that product rounded up. Positions count from one. + """ + count = len(rents) + position = count * percentile + whole = round(position) + if abs(position - whole) < 1e-9 and 1 <= whole < count: + return (rents[whole - 1] + rents[whole]) / 2 + index = min(max(int(np.ceil(position - 1e-9)), 1), count) + return rents[index - 1] + + +@lru_cache(maxsize=16) +def _percentile_ratios(percentile: float) -> np.ndarray: + """Rent at ``percentile`` relative to the 30th, per BRMA and category. + + The published tables give only the 30th percentile, so another percentile + is reached by scaling it by this ratio from the model's list of rents. + English and Scottish BRMAs use their own lists (April 2020 for both). + The Welsh and Northern Irish lists are copies of English ones (see + ``_sorted_list_of_rents``), so those BRMAs take the median English ratio + for the same category. Cells with no list keep a ratio of one. + """ + rates = published_rates() + lists, regions = _sorted_list_of_rents() + copied = {b for b, r in regions.items() if r in COPIED_LIST_REGIONS} + ratios = np.ones((len(rates.brmas), len(CATEGORIES))) + english = {category: [] for category in CATEGORIES} + for (brma, category), rents in lists.items(): + position = rates.brmas.get_indexer([brma])[0] + if position < 0 or category not in CATEGORIES or brma in copied: + continue + base = statutory_percentile(rents, PUBLISHED_PERCENTILE) + if base > 0: + ratio = statutory_percentile(rents, percentile) / base + ratios[position, CATEGORIES.index(category)] = ratio + if regions[brma] not in NON_ENGLISH_REGIONS: + english[category].append(ratio) + for brma in copied: + position = rates.brmas.get_indexer([brma])[0] + if position < 0: + continue + for index, category in enumerate(CATEGORIES): + if english[category]: + ratios[position, index] = float(np.median(english[category])) + return ratios + + +def find_freeze_anchor(freeze_parameter, period: str) -> str: + """Finds the instant whose rates a frozen LHA rate is held at. + + Frozen rates are held in cash terms at the level set in the most recent + year in which LHA was *not* frozen, so this returns the start instant of + the latest unfrozen period before the given period. + + Args: + freeze_parameter (Parameter): The LHA freeze parameter. + period (str): The period to search up to. + + Returns: + str: The instant of the latest unfrozen period, or None if not frozen. + """ + # Values at or before the requested period, newest first. + relevant_values = [ + v for v in freeze_parameter.values_list if v.instant_str <= str(period) + ] + + if not relevant_values: + return None + + if not relevant_values[0].value: + # Not currently frozen. + return None + + # Walk back to the most recent value that is False; the rates in force + # during the freeze are the ones determined in that year. + for value in relevant_values: + if not value.value: + return value.instant_str + + # Frozen for the whole of the parameter's history; fall back to the + # oldest value available. + return relevant_values[-1].instant_str + + +def determination_year(lha, year: int) -> int: + """The year whose determination is in force in ``year``. + + A year is a UK fiscal year: parameters are read at 30 April, so ``year`` + 2025 is April 2025 to March 2026 and takes the determination made in + January 2025, or the one it is frozen at. + """ + if lha.freeze(str(year)): + return int(find_freeze_anchor(lha.freeze, f"{year}-01-01")[:4]) + return int(year) + + +def restatement(measure: str, determined: int, year: int) -> np.ndarray: + """How later held tables restate the rates determined in ``determined``. + + A table that holds an earlier determination should repeat its rates, and + in England it always does. Rent Officers Wales's tables for April 2022 and + April 2023, which say the rates are fixed at the April 2020 rate, give + different figures from its April 2020 and April 2021 tables for some + cells, and DWP's monthly tables follow them. The model takes each table + as published: this returns the change from the determination to the latest + held table by ``year`` (zero almost everywhere). It is applied to the held + determination's percentile rent, before the maximum, anomalous-rate and + minimum rules, so that a reform to those rules still binds. + """ + rates = published_rates() + original = rates.at(measure, determined) + change = np.zeros(original.shape) + for table_year in sorted(HELD_TABLES): + if HELD_TABLES[table_year] == determined and table_year <= year: + held = rates.at(measure, table_year) + known = ~np.isnan(held) & ~np.isnan(original) + change[known] = (held - original)[known] + return change + + +def march_2020_rates(universal_credit: bool = False) -> np.ndarray: + """The rates determined on 31 March 2020, for the minimum (para 3A). + + The April 2020 tables publish those determinations. Rent Officers Wales's + April 2022 and April 2023 tables, which hold them, restate 14 weekly (17 + monthly) of them. Each restated figure is a round rent (16 of the 17 + monthly figures are multiples of GBP 5; the 17th, Vale of Glamorgan one + bedroom at GBP 434.52, is GBP 100 a week), which reads as a correction to + the percentile, so the minimum uses the latest held table's figure. Northern + Ireland's monthly Universal Credit rates were not published before April + 2024, so for them the weekly rate is converted as the model converts any + weekly percentile (to within about 3p of the Housing Executive's figure). + """ + rates = published_rates() + latest_hold = max(y for y, d in HELD_TABLES.items() if d == FIRST_RULES_YEAR) + weekly = rates.at("rate", FIRST_RULES_YEAR) + restatement( + "rate", FIRST_RULES_YEAR, latest_hold + ) + if not universal_credit: + return weekly + monthly = rates.at("uc_rate", FIRST_RULES_YEAR) + restatement( + "uc_rate", FIRST_RULES_YEAR, latest_hold + ) + return np.where( + np.isnan(monthly), round_half_up(weekly * WEEKLY_TO_MONTHLY), monthly + ) + + +def determination( + parameters, + determined: int, + universal_credit: bool = False, + adjustment: np.ndarray | None = None, +): + """The rates determined in ``determined`` for every BRMA and category. + + ``adjustment`` is added to the percentile rents before the rules apply + (see ``restatement``). Returns (percentile, rate) as [BRMA, category] + arrays, weekly for Housing Benefit and monthly for Universal Credit. + """ + lha = parameters.gov.dwp.LHA + rates = published_rates() + + if determined < FIRST_RULES_YEAR: + rate, _ = rates.latest("rate", determined) + percentile, _ = rates.latest("percentile_30", determined) + if universal_credit: + uc_rate, uc_year = rates.latest("uc_rate", determined) + published_by_then = uc_year <= determined + # Where no monthly rate was published (Northern Ireland), convert + # the weekly rate as the determinations from April 2020 do. + rate = np.where( + published_by_then & ~np.isnan(uc_rate), + uc_rate, + round_half_up(rate * WEEKLY_TO_MONTHLY), + ) + percentile = round_half_up(percentile * WEEKLY_TO_MONTHLY) + percentile = np.where(np.isnan(percentile), rate, percentile) + return percentile, rate + + weekly, base_year = rates.latest("percentile_30", determined) + if universal_credit: + published_monthly = np.full(weekly.shape, np.nan) + for value in np.unique(base_year): + in_year = base_year == value + published_monthly[in_year] = rates.at("uc_percentile_30", value)[in_year] + percentile = np.where( + np.isnan(published_monthly), weekly * WEEKLY_TO_MONTHLY, published_monthly + ) + else: + percentile = weekly + + # Rents grow from the year the latest percentile was published to the + # determination year. + index = parameters.gov.indices.private_rent_index + growth = np.ones(weekly.shape) + for value in np.unique(base_year[~np.isnan(weekly)]): + if value < determined: + growth[base_year == value] = index(str(determined)) / index(str(value)) + percentile = percentile * growth + + share = lha.percentile(str(determined)) + if abs(share - PUBLISHED_PERCENTILE) > 1e-9: + percentile = percentile * _percentile_ratios(float(share)) + if adjustment is not None: + percentile = percentile + adjustment + percentile = round_half_up(percentile) + + maxima = lha.maximum_monthly if universal_credit else lha.maximum + cap = np.array( + [maxima.children[category](str(determined)) for category in CATEGORIES] + ) + # Sch 3B para 2(2) and (10): the lower of the two, in whole pence. + rate = round_half_up(np.minimum(percentile, cap[None, :])) + # Sch 3B para 3: no category below a smaller one. + rate = np.maximum.accumulate(rate, axis=1) + if lha.march_2020_minimum(str(determined)): + # Sch 3B para 3A: no rate below the one determined on 31 March 2020. + minimum = march_2020_rates(universal_credit) + rate = np.where(np.isnan(minimum), rate, np.maximum(rate, minimum)) + return percentile, rate + + +def lha_rates(parameters, year: int, universal_credit: bool = False) -> dict: + """LHA rates in force in ``year`` for every BRMA and category. + + Args: + parameters: The root parameter node (``tax_benefit_system.parameters``). + year: The fiscal year the rates apply in. + universal_credit: Monthly Universal Credit rates (Rent Officers + (Universal Credit Functions) Order 2013) rather than weekly + Housing Benefit ones. + + Returns: + A dict with ``brmas`` (a pandas Index of BRMA names), ``percentile`` + (the rent at the percentile, before the maximum, anomalous-rate and + minimum rules) and ``rate`` (the LHA in force), both [BRMA, category] + arrays, weekly for Housing Benefit and monthly for Universal Credit. + """ + lha = parameters.gov.dwp.LHA + determined = determination_year(lha, year) + adjustment = None + if determined != year and determined >= FIRST_RULES_YEAR: + measure = "uc_rate" if universal_credit else "rate" + adjustment = restatement(measure, determined, year) + percentile, rate = determination( + parameters, determined, universal_credit, adjustment + ) + return dict(brmas=published_rates().brmas, percentile=percentile, rate=rate) + + +def benunit_lha( + benunit, + period, + measure: str, + universal_credit: bool = False, + category_variable: str = "LHA_category", +): + """Look up an LHA measure for each benefit unit's BRMA and category. + + ``category_variable`` names the category of accommodation: the Universal + Credit one by default, or ``housing_benefit_LHA_category``. Rent officers + determine one rate per BRMA and category, so the two benefits read the + same table; they differ only in which category applies to the renter. + """ + parameters = benunit.simulation.tax_benefit_system.parameters + table = lha_rates(parameters, period.start.year, universal_credit) + brma = benunit.value_from_first_person( + benunit.members.household("brma", period).decode_to_str() + ) + category = benunit(category_variable, period).decode_to_str() + brma_index = table["brmas"].get_indexer(brma) + category_index = pd.Index(CATEGORIES).get_indexer(category) + values = table[measure][np.maximum(brma_index, 0), np.maximum(category_index, 0)] + return np.where((brma_index < 0) | (category_index < 0), np.nan, values) diff --git a/policyengine_uk/variables/gov/dwp/BRMA_LHA_rate.py b/policyengine_uk/variables/gov/dwp/BRMA_LHA_rate.py index 3a48583431..2fdcb9cac4 100644 --- a/policyengine_uk/variables/gov/dwp/BRMA_LHA_rate.py +++ b/policyengine_uk/variables/gov/dwp/BRMA_LHA_rate.py @@ -1,32 +1,29 @@ from policyengine_uk.model_api import * -import pandas as pd -import warnings -from policyengine_core.model_api import * -from policyengine_uk.variables.gov.dwp.LHA_category import ( - category_maximum, - MONTHLY_MAXIMUM_FIRST_YEAR, -) - -warnings.filterwarnings("ignore") +from policyengine_uk.utils.lha import benunit_lha class BRMA_LHA_rate(Variable): value_type = float entity = BenUnit label = "LHA rate" - documentation = "Local Housing Allowance rate, capped at the national maximum" + documentation = ( + "Weekly Housing Benefit Local Housing Allowance for the benefit unit's " + "Broad Rental Market Area and LHA category, annualised over 52 weeks" + ) definition_period = YEAR unit = GBP - reference = "https://www.legislation.gov.uk/uksi/1997/1984/schedule/3B" + reference = [ + "https://www.legislation.gov.uk/uksi/1997/1984/schedule/3B", + "https://www.gov.uk/government/collections/local-housing-allowance-lha-rates", + ] def formula(benunit, period, parameters): - """The published Housing Benefit rate. + """The determined Housing Benefit rate. - Rates are the lower of the Broad Rental Market Area percentile and the - weekly national maximum for the category (Rent Officers (Housing - Benefit Functions) Order 1997, Schedule 3B). Universal Credit has its - own monthly maximum: see ``uc_LHA_cap``. + The lower of the BRMA percentile rent and the weekly national maximum, + raised to the rate of any smaller category and, from April 2024, to the + rate determined on 31 March 2020 (Rent Officers (Housing Benefit + Functions) Order 1997, Schedule 3B paragraphs 2, 3 and 3A). Universal + Credit has its own monthly determination: see ``uc_LHA_cap``. """ - rate = benunit("uncapped_BRMA_LHA_rate", period) - maximum = category_maximum(benunit, period, "maximum") - return min_(rate, maximum * 52) + return benunit_lha(benunit, period, "rate") * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/LHA_category.py b/policyengine_uk/variables/gov/dwp/LHA_category.py index ecd771d7d4..d06dcf02e3 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_category.py +++ b/policyengine_uk/variables/gov/dwp/LHA_category.py @@ -1,9 +1,4 @@ from policyengine_uk.model_api import * -import pandas as pd -import warnings -from policyengine_core.model_api import * - -warnings.filterwarnings("ignore") class LHACategory(Enum): @@ -60,101 +55,3 @@ def formula(benunit, period, parameters): LHACategory.E, ], ) - - -def time_shift_dataset( - df: pd.DataFrame, year: int, private_rent_index: Parameter -) -> pd.DataFrame: - """Check if we have rows of data for the given year. If so, remove all other years. If not, select the latest year rows and uprate using the private rent index. - - Args: - df (pd.DataFrame): The List of Rents. - year (int): The requests year. - private_rent_index (Parameter): The private rent index. - - Returns: - pd.DataFrame: The List of Rents for the given year. - """ - year = int(year) - df.year = df.year.astype(int) - if year in df.year.unique(): - df = df[df.year == year] - else: - df = df[df.year == df.year.max()] - start_instant = f"{df.year.max()}-01-01" - end_instant = f"{year}-01-01" - start_index = private_rent_index(start_instant) - end_index = private_rent_index(end_instant) - uprating_index = end_index / start_index - df.weekly_rent = np.round(df.weekly_rent * uprating_index, 2) - df.year = year - return df - - -def find_freeze_anchor(freeze_parameter: Parameter, period: str) -> str: - """Finds the instant whose rents a frozen LHA rate should be based on. - - Frozen rates are held in cash terms at the level set in the most recent - year in which LHA was *not* frozen, so this returns the start instant of - the latest unfrozen period before the given period. - - Args: - freeze_parameter (Parameter): The LHA freeze parameter. - period (str): The period to search up to. - - Returns: - str: The instant of the latest unfrozen period, or None if not frozen. - """ - # Values at or before the requested period, newest first. - relevant_values = [ - v for v in freeze_parameter.values_list if v.instant_str <= str(period) - ] - - if not relevant_values: - return None - - if not relevant_values[0].value: - # Not currently frozen. - return None - - # Walk back to the most recent value that is False; the rates in force - # during the freeze are the ones determined in that year. - for value in relevant_values: - if not value.value: - return value.instant_str - - # Frozen for the whole of the parameter's history; fall back to the - # oldest value available. - return relevant_values[-1].instant_str - - -# Universal Credit's monthly national maximum is only modelled from April -# 2020. No monthly maximum existed in 2016, and the 2017 to 2019 figures were -# targeted affordability caps applying to listed areas rather than nationally. -# Parameters are backdated to 2015 on load, so the series has to be gated -# here: omitting the early YAML entries does not stop the lookup returning -# the 2020 figure for earlier years. -MONTHLY_MAXIMUM_FIRST_YEAR = 2020 - - -def category_maximum( - benunit, period, node_name: str, category_variable: str = "LHA_category" -): - """Per-category national maximum, read at the determination year. - - Frozen rates are held at the level last determined, so the maximum in - force then is the one that binds, not the current year's. - ``category_variable`` names the category to look up: the Universal - Credit one by default, or the Housing Benefit one. - """ - lha = benunit.simulation.tax_benefit_system.parameters.gov.dwp.LHA - - if lha.freeze(period): - determination_period = find_freeze_anchor(lha.freeze, period.start)[:4] - else: - determination_period = str(period.start.year) - - node = getattr(lha, node_name) - category = benunit(category_variable, period).decode_to_str() - caps = {cat: node.children[cat](determination_period) for cat in node.children} - return pd.Series(category).map(caps).to_numpy(dtype=float) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_rate.py b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_rate.py index b21e8c6e4d..4c1c8132ea 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_rate.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit_LHA_rate.py @@ -1,8 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.variables.gov.dwp.LHA_category import category_maximum -from policyengine_uk.variables.gov.dwp.uncapped_BRMA_LHA_rate import ( - lha_rate_for_category, -) +from policyengine_uk.utils.lha import benunit_lha class housing_benefit_LHA_rate(Variable): @@ -11,18 +8,33 @@ class housing_benefit_LHA_rate(Variable): label = "LHA rate (Housing Benefit)" documentation = ( "The Local Housing Allowance for the Housing Benefit category of " - "dwelling: the Broad Rental Market Area rate, capped at the weekly " - "national maximum for the category." + "dwelling: the weekly rate determined for the Broad Rental Market " + "Area and category, annualised over 52 weeks." ) definition_period = YEAR unit = GBP reference = ( "https://www.legislation.gov.uk/uksi/2006/213/regulation/13D", "https://www.legislation.gov.uk/uksi/1997/1984/schedule/3B", + "https://www.gov.uk/government/collections/local-housing-allowance-lha-rates", ) def formula(benunit, period, parameters): - category = "housing_benefit_LHA_category" - rate = lha_rate_for_category(benunit, period, category) - maximum = category_maximum(benunit, period, "maximum", category) - return min_(rate, maximum * 52) + """The determined Housing Benefit rate for the HB category. + + The same determination as ``BRMA_LHA_rate`` (the lower of the BRMA + percentile rent and the weekly national maximum, the anomalous-rate + rule and, from April 2024, the 31 March 2020 minimum: Rent Officers + (Housing Benefit Functions) Order 1997, Schedule 3B paragraphs 2, 3 + and 3A), read for the category in HB Regulations 2006 reg 13D rather + than the Universal Credit one. + """ + return ( + benunit_lha( + benunit, + period, + "rate", + category_variable="housing_benefit_LHA_category", + ) + * WEEKS_IN_YEAR + ) diff --git a/policyengine_uk/variables/gov/dwp/uc_LHA_cap.py b/policyengine_uk/variables/gov/dwp/uc_LHA_cap.py index 07cfa6b2b6..461afa71f8 100644 --- a/policyengine_uk/variables/gov/dwp/uc_LHA_cap.py +++ b/policyengine_uk/variables/gov/dwp/uc_LHA_cap.py @@ -1,13 +1,5 @@ from policyengine_uk.model_api import * -import pandas as pd -import warnings -from policyengine_core.model_api import * -from policyengine_uk.variables.gov.dwp.LHA_category import ( - category_maximum, - MONTHLY_MAXIMUM_FIRST_YEAR, -) - -warnings.filterwarnings("ignore") +from policyengine_uk.utils.lha import benunit_lha class uc_LHA_cap(Variable): @@ -17,23 +9,20 @@ class uc_LHA_cap(Variable): documentation = "Rent covered by the Local Housing Allowance for Universal Credit" definition_period = YEAR unit = GBP - reference = "https://www.legislation.gov.uk/uksi/2013/382/schedule/1" + reference = [ + "https://www.legislation.gov.uk/uksi/2013/382/schedule/1", + "https://www.gov.uk/government/collections/universal-credit-local-housing-allowance-rates", + ] def formula(benunit, period, parameters): - """Universal Credit applies a monthly national maximum. + """Universal Credit uses a monthly LHA determination. - The monthly figures in Schedule 1 to the Rent Officers (Universal - Credit Functions) Order 2013 are set independently of the weekly - Housing Benefit maxima and are slightly higher, so annualising the - weekly figure would impose a ceiling below the statutory one. + Schedule 1 to the Rent Officers (Universal Credit Functions) Order 2013 + determines monthly rates from monthly rents, with monthly national + maxima set independently of the weekly Housing Benefit ones, so the + weekly rate is not simply annualised. Before April 2020 the published + monthly rates are used as they stand. """ rent = benunit("benunit_rent", period) - - if period.start.year < MONTHLY_MAXIMUM_FIRST_YEAR: - # Before the monthly series begins, fall back to the weekly - # Housing Benefit rate, as the model did previously. - return min_(rent, benunit("BRMA_LHA_rate", period)) - - rate = benunit("uncapped_BRMA_LHA_rate", period) - maximum = category_maximum(benunit, period, "maximum_monthly") - return min_(rent, min_(rate, maximum * MONTHS_IN_YEAR)) + monthly = benunit_lha(benunit, period, "rate", universal_credit=True) + return min_(rent, monthly * MONTHS_IN_YEAR) diff --git a/policyengine_uk/variables/gov/dwp/uncapped_BRMA_LHA_rate.py b/policyengine_uk/variables/gov/dwp/uncapped_BRMA_LHA_rate.py index 27658fd46e..f7913225ed 100644 --- a/policyengine_uk/variables/gov/dwp/uncapped_BRMA_LHA_rate.py +++ b/policyengine_uk/variables/gov/dwp/uncapped_BRMA_LHA_rate.py @@ -1,89 +1,24 @@ from policyengine_uk.model_api import * -import pandas as pd -import warnings -from policyengine_core.model_api import * -from policyengine_uk.variables.gov.dwp.LHA_category import ( - find_freeze_anchor, - time_shift_dataset, -) - -warnings.filterwarnings("ignore") - - -def lha_rate_for_category(benunit, period, category_variable="LHA_category"): - """The Broad Rental Market Area LHA rate (annual) for a category. - - ``category_variable`` names the category: the Universal Credit one by - default, or the Housing Benefit one. - """ - brma = benunit.value_from_first_person( - benunit.members.household("brma", period).decode_to_str() - ) - category = benunit(category_variable, period).decode_to_str() - - from policyengine_uk.parameters.gov.dwp.LHA import lha_list_of_rents - - parameters = benunit.simulation.tax_benefit_system.parameters - lha = parameters.gov.dwp.LHA - - # We first need to know what time period to collect rents from. If LHA is frozen, we need to look earlier - # than the current time period. - - frozen = lha.freeze(period) - if frozen: - # Rates are held at the level last determined, so every input to - # the determination is read at that year, not the current one. - freeze_anchor = find_freeze_anchor(lha.freeze, period.start) - lha_period = int(freeze_anchor[:4]) # Get year - else: - lha_period = int(period.start.year) - - determination_period = str(lha_period) - - private_rent_index = parameters.gov.indices.private_rent_index - lha_list_of_rents = time_shift_dataset( - lha_list_of_rents.copy(), lha_period, private_rent_index - ) - - percentile = lha.percentile(determination_period) - - lha_rates = lha_list_of_rents.groupby( - ["brma", "lha_category"] - ).weekly_rent.quantile(percentile) - - # Convert MultiIndex Series to DataFrame for merge - lha_rates_df = lha_rates.reset_index() - lha_rates_df.columns = ["brma", "lha_category", "weekly_rent"] - - # Determined rates are rounded to the nearest penny, half up - # (Schedule 3B paragraph 2(10)); np.round is half-even. Pence are - # snapped to 6dp first, because an exact half such as 298.835 is - # held as 29883.499999999996 once scaled and would round down. - lha_rates_df.weekly_rent = ( - np.floor(np.round(lha_rates_df.weekly_rent * 100, 6) + 0.5) / 100 - ) - - lha_lookup_table = pd.DataFrame( - { - "brma": brma, - "lha_category": category, - } - ) - # Use merge instead of row-by-row apply for vectorised lookup - lha_lookup_table = lha_lookup_table.merge( - lha_rates_df, on=["brma", "lha_category"], how="left" - ) - return lha_lookup_table.weekly_rent.values * 52 +from policyengine_uk.utils.lha import benunit_lha class uncapped_BRMA_LHA_rate(Variable): value_type = float entity = BenUnit - label = "Uncapped LHA rate" - documentation = "Local Housing Allowance rate before the national maximum" + label = "LHA percentile rent" + documentation = ( + "Weekly rent at the LHA percentile (30th by default) of the Broad Rental " + "Market Area's list of rents for the benefit unit's LHA category, in the " + "year whose determination is in force, annualised over 52 weeks. This is " + "before the national maximum, the anomalous-rate rule and the March 2020 " + "minimum." + ) definition_period = YEAR unit = GBP - reference = "https://www.legislation.gov.uk/uksi/1997/1984/schedule/3B" + reference = [ + "https://www.legislation.gov.uk/uksi/1997/1984/schedule/3B", + "https://www.gov.uk/government/collections/local-housing-allowance-lha-rates", + ] def formula(benunit, period, parameters): - return lha_rate_for_category(benunit, period) + return benunit_lha(benunit, period, "percentile") * WEEKS_IN_YEAR diff --git a/scripts/build_lha_published_rates.py b/scripts/build_lha_published_rates.py new file mode 100644 index 0000000000..b80d38ddc8 --- /dev/null +++ b/scripts/build_lha_published_rates.py @@ -0,0 +1,10 @@ +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from policyengine_uk.utils.build_lha_published_rates import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build_scottish_list_of_rents.py b/scripts/build_scottish_list_of_rents.py new file mode 100644 index 0000000000..1eeea51dd6 --- /dev/null +++ b/scripts/build_scottish_list_of_rents.py @@ -0,0 +1,10 @@ +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from policyengine_uk.utils.build_scottish_list_of_rents import main + + +if __name__ == "__main__": + raise SystemExit(main())