From 82f30ef452bb8c6e4a70090365f88d44bfa0f618 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 16:54:48 -0400 Subject: [PATCH 1/8] Let a target declare the countries its source covers Copies, verbatim, the `countries` field and GREAT_BRITAIN constant (targets/schema.py) and `restrict_to_countries` (targets/build_loss_matrix.py) that #490 and #530 add, so all three PRs make the identical change. Also copies #526's hypothesis dev dependency (pyproject.toml and uv.lock hunks identical to #526's and #530's) for the property tests. Co-Authored-By: Claude Opus 5.5 --- .../targets/build_loss_matrix.py | 14 +++- policyengine_uk_data/targets/schema.py | 9 +++ pyproject.toml | 1 + uv.lock | 73 ++++++++++++++++++- 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/policyengine_uk_data/targets/build_loss_matrix.py b/policyengine_uk_data/targets/build_loss_matrix.py index 9552aeea2..44b34272c 100644 --- a/policyengine_uk_data/targets/build_loss_matrix.py +++ b/policyengine_uk_data/targets/build_loss_matrix.py @@ -113,7 +113,7 @@ def create_target_matrix( col = _compute_column(target, ctx, year) if col is None: continue - df[target.name] = col + df[target.name] = restrict_to_countries(col, ctx.country, target.countries) target_names.append(target.name) target_values.append(val) except Exception as e: @@ -122,6 +122,18 @@ def create_target_matrix( return df, pd.Series(target_values, index=target_names) +def restrict_to_countries(column, household_country, countries): + """Zero a household column outside the countries a target covers. + + ``countries`` of None means the target covers the whole UK, and the + column is returned unchanged. + """ + if countries is None: + return column + in_scope = np.isin(np.asarray(household_country), countries) + return np.asarray(column, dtype=float) * in_scope + + def _resolve_value(target: Target, year: int) -> float | None: """Get the target value for a year, falling back to nearest year. diff --git a/policyengine_uk_data/targets/schema.py b/policyengine_uk_data/targets/schema.py index 97b814678..0a678a228 100644 --- a/policyengine_uk_data/targets/schema.py +++ b/policyengine_uk_data/targets/schema.py @@ -19,6 +19,11 @@ class Unit(str, Enum): RATE = "rate" +# DWP statistics cover Great Britain: benefits for Northern Ireland residents +# are the Northern Ireland Executive's responsibility. +GREAT_BRITAIN = ("ENGLAND", "SCOTLAND", "WALES") + + class Target(BaseModel): """A single calibration target from an official statistical source. @@ -41,6 +46,10 @@ class Target(BaseModel): is_count: bool = False reference_url: str | None = None forecast_vintage: str | None = None + # Countries a national source covers when that is less than the UK, as + # values of the model's `country` variable. The loss matrix column only + # counts households in these countries. None means the whole UK. + countries: tuple[str, ...] | None = None # For targets needing custom simulation logic (UC splits, # counterfactuals). Excluded from serialisation. diff --git a/pyproject.toml b/pyproject.toml index beff7f1a4..58de5b1d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ dev = [ "ruff>=0.9.0", "pytest", + "hypothesis", "torch", "l0-python>=0.4.0", "tables", diff --git a/uv.lock b/uv.lock index a3e9f44d9..c301c6c9f 100644 --- a/uv.lock +++ b/uv.lock @@ -577,6 +577,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/f4/124858007ddf3c61e9b144107304c9152fa80b5b6c168da07d86fe583cc1/huggingface_hub-1.1.5-py3-none-any.whl", hash = "sha256:e88ecc129011f37b868586bbcfae6c56868cae80cd56a79d61575426a3aa0d7d", size = 516000, upload-time = "2025-11-20T15:49:30.926Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b7/13118bbc45d6d8b9d04e2de779e2a4ff23145ea39efa692b994fb874ca72/hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75", size = 511022, upload-time = "2026-09-28T05:20:58.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/cd/746a2fc1e5e5ba34f07ea6ee1ad07525a3dde5ff4836db3a7980af49a891/hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb", size = 791536, upload-time = "2026-09-28T05:18:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7a158e377b69556c8e12c25c8fd811d0103e54c24a12dab1f3b9819f202e/hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c", size = 787313, upload-time = "2026-09-28T05:20:18.122Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/7df9104ee359e8fcd77791781c47e70794964ca69673665e0de2a6590f4c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e", size = 1124036, upload-time = "2026-09-28T05:20:04.372Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/8a61715404a73b9a82cb1f976803428d603cdea976a9c30266c72ad6a7ce/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7", size = 1147875, upload-time = "2026-09-28T05:19:15.495Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3f/2b16e95cc3b9a069afa23830a012aefe377ce4a457a227e5a5026eb827ff/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d", size = 1149489, upload-time = "2026-09-28T05:19:18.886Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e7/d7cf6dc068bb02732b2a6e38a35a0dcb7f2137608198fc79d740f69d197c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c", size = 1191992, upload-time = "2026-09-28T05:19:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/f1e5b25dec15998e8375d722825ec3dae1075cbe7cb1f74221b2312a2e33/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef", size = 1169911, upload-time = "2026-09-28T05:19:27.996Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/901234a49147e6bf8a5b9e54b775706a223c2b52befea1106ea2aaf15d48/hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef", size = 1129406, upload-time = "2026-09-28T05:18:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/55/c1/5fc9ff91ec9fba6bb527833386c66812524b5cc75fe7d4a4f9122ee1c420/hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd", size = 1160399, upload-time = "2026-09-28T05:20:23.911Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/49ad1ef5c55547c8abfd0fc571b3493806dda530fd5cb3d8263dbad86a72/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595", size = 1299906, upload-time = "2026-09-28T05:18:58.417Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/f98a26094c4f462c4215807bed0f9a6b5508b27a3c329bcba1bc8adc7ac7/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11", size = 1425538, upload-time = "2026-09-28T05:20:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b8/c5b4406e3a6591e51a42f85469351d1d824e0a54b84167aec9b2353759fe/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab", size = 1377084, upload-time = "2026-09-28T05:20:06.618Z" }, + { url = "https://files.pythonhosted.org/packages/e0/01/0d84a8ea469d024c15f602799c0b8410fbe03d99fdb0370449b33c3c916f/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd", size = 1281162, upload-time = "2026-09-28T05:19:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/22/b5/ea6435038de1a795f005cb603531e8ccc053febc50180f3ab55583c9c60e/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef", size = 1300433, upload-time = "2026-09-28T05:18:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/6c/78/fa6c77d9f64b6d592e69e582d83cbce7d7e56897faa4762885a2123b0166/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026", size = 1336273, upload-time = "2026-09-28T05:19:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/f1/56/4acd0d778bab818c9ea336fcba768bf43bc70db7f84cc373579298df4641/hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4", size = 678211, upload-time = "2026-09-28T05:19:23.355Z" }, + { url = "https://files.pythonhosted.org/packages/b0/db/0a02b146ad1c30f9716362f2e1a379dfd6b0f7be03ac1e22c9da24c9e2a9/hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24", size = 684906, upload-time = "2026-09-28T05:18:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c0/958deaf726848f96f52250740bf39f13f476b068e22f73e2b358eaa07532/hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a", size = 683292, upload-time = "2026-09-28T05:19:57.404Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a2/6787da846d929e52fc3344d803299c45782dbeac287528af08380e984bf8/hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7", size = 793254, upload-time = "2026-09-28T05:19:36.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/ba/2893f5ca42501f4d562cba3229c8994c3a3e5fac66ef60c1e0e0b5220a5d/hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499", size = 784699, upload-time = "2026-09-28T05:18:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/b7/aa/7d7349daf75b71f6f35876f8de115e974c5c04d600e86df1b2779b0883ab/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89", size = 1123016, upload-time = "2026-09-28T05:20:36.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/7774e1ea072708ea46cb25c4aeee5f9978b6c271764809069e8a6789858e/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0", size = 1168989, upload-time = "2026-09-28T05:20:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7d/113992abed9efbd7944e3a496a382da58152dce126fddf6419ff31a0a57d/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a", size = 1298626, upload-time = "2026-09-28T05:19:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/d5/65/3659fa5e733027e5b37a27486e57f4053535e40853d23501c422bb275043/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248", size = 1335184, upload-time = "2026-09-28T05:20:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6c/35aab2221b5ea65125340f236e1a765a9e9f3b28d89a389ea0033fed29f7/hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766", size = 682173, upload-time = "2026-09-28T05:19:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/27b0cb56ff5d2bc92458bf6fcebdb1f0dc01f57f043a178e9f9d74498169/hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2", size = 793287, upload-time = "2026-09-28T05:20:38.522Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bd/5342f95c3bc36586777ef8cb8eba87ac7acf0184210327fb0e6d8654e295/hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba", size = 784857, upload-time = "2026-09-28T05:20:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/fc774241518a0588d362680a3084275058aab86d6bc02a2c61e1073142d0/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897", size = 1123330, upload-time = "2026-09-28T05:19:29.735Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/131f16775a3dca5f4fe27f0d6ad9a9851600098a54a872d163f6ebf0b664/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b", size = 1169178, upload-time = "2026-09-28T05:19:53.436Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/c9f5bff8b73321c9fd12f2d69666c6e4861b97b60f5f24fdd6ac293007fd/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2", size = 1299127, upload-time = "2026-09-28T05:20:14.486Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/d4618f8ab12dd76c4d58ea052252759aa2bf0c4e71f4b502ba5d577e0b9f/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86", size = 1335390, upload-time = "2026-09-28T05:20:10.326Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/727161e17cc297df1aeea945de05c532033b66751625bec192bc5b5ff707/hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa", size = 624291, upload-time = "2026-09-28T05:18:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/77/23/2f9b506392a0b8712440bcb781abc5713be9ffac1303b6d10f3669050882/hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2", size = 682064, upload-time = "2026-09-28T05:20:16.336Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/efabfd95eb2b69c0c9fa1e1c83c8290aa2715d46baeb5a7764faf09c133a/hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074", size = 791875, upload-time = "2026-09-28T05:19:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/2a0ada1623a9a33048bbf9f00a7c92513398c6b8865cba5efb854262ce30/hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe", size = 783412, upload-time = "2026-09-28T05:18:48.266Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/73c615d37eeb12da0209555612c32ced628d860267c23cfd2691c6207aea/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675", size = 1121662, upload-time = "2026-09-28T05:19:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/92/54/1893344a3b8bbf83fa7c9bb7e96f6836580213854f46671cb749f7b4706c/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4", size = 1167761, upload-time = "2026-09-28T05:18:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a61d82968febd25a0f08ffbbfabfaeead66f68fc5fa73f3c9ece1c36fc8f/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7", size = 1297309, upload-time = "2026-09-28T05:19:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/fe8e4cf6d39d02f4efadaa351427fa13722086cfc54a2da3579741c5177a/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96", size = 1334260, upload-time = "2026-09-28T05:19:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/39/6a/09177ebc62f4778f94379ecade6a51299d3d8c4f4b75ba636bf3a0202364/hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f", size = 681992, upload-time = "2026-09-28T05:19:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/890bf33d608cd63348d3146a3ca83362fbf09e589c5e5230a00c403070f4/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f", size = 791231, upload-time = "2026-09-28T05:18:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/03/dd/fc36b204f8aa7437c676576d9437f0422de46aa8a729e6cd5bbb0224e759/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b", size = 783171, upload-time = "2026-09-28T05:19:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d2/3cd087d577db67c404991d7723e64d7cd75570cdc82a0f7329aed6f2086c/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408", size = 1121152, upload-time = "2026-09-28T05:18:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/42/6f/a05a68cc66e7a22701c50bf94fb9ddeb36f4f5d0de3cc130066709eda621/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa", size = 1144069, upload-time = "2026-09-28T05:20:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/fdd7a093fb2fb3a0ce24256c92ad5bf67936f4669bd06d5ceae4298150ec/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128", size = 1146692, upload-time = "2026-09-28T05:20:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6dbd4377e935505ae4fc8ee4c7b18c69994c4bbaca015447c470218bdbee/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38", size = 1189480, upload-time = "2026-09-28T05:20:46.734Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bf/ff288b496b690000d2686dcfa7f67855e4c5a6dddb464ed8e4880be83bb2/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc", size = 1167109, upload-time = "2026-09-28T05:19:24.758Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d1/3fee2bc29fc747fabbb27e174515592e390809ca465d3bbbabbcfa3235a8/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6", size = 1126823, upload-time = "2026-09-28T05:18:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/58/6a/585294392fa6a6d9719446a042a777ba98ecca265d9e48cbedc10c88df0f/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483", size = 1155773, upload-time = "2026-09-28T05:20:51.268Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/fa283ff79996debf1ff08593e01f8b773eb8211b19b3d6d5d491317a65bf/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09", size = 1296671, upload-time = "2026-09-28T05:18:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/42/05/98f9c2f628da5afabb6c3e8df5d2d299d2d50bf306b14a6e26d3954c4951/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580", size = 1422001, upload-time = "2026-09-28T05:19:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/f2109ade29a7ec284d55bca328d784743e8b13e321576fd34ee7e65f99af/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5", size = 1374182, upload-time = "2026-09-28T05:20:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/e05d5f72441564a3bebc71fa155deafd0fd3b015d6014ec8a00edf6e42bb/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62", size = 1278402, upload-time = "2026-09-28T05:20:56.332Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/6988a7f1f69c5c530687dce03a32c157e2d878e3a2e32af9269ce016b78a/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd", size = 1297770, upload-time = "2026-09-28T05:19:11.387Z" }, + { url = "https://files.pythonhosted.org/packages/a2/28/5bb82b60b836bd2329e1fe01ad94efc2b14dfa806f8e50ed14b795d78770/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16", size = 1333977, upload-time = "2026-09-28T05:20:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7d/d419841b8f65481ea1a50c4ba36f2670d48b8c7a51e4e8586089564cf7f6/hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957", size = 675394, upload-time = "2026-09-28T05:19:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/1de6a2ad100e44817f2e9a8e8ba3eeaf4769621f4f87f2d4966c4971fcc4/hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a", size = 681699, upload-time = "2026-09-28T05:19:09.957Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/be3370fd02734d6b1d950183ae9224580d19fa8d356b95348d60f1e3eeb7/hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5", size = 679849, upload-time = "2026-09-28T05:19:06.884Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1364,7 +1433,7 @@ wheels = [ [[package]] name = "policyengine-uk-data" -version = "1.56.16" +version = "1.57.4" source = { editable = "." } dependencies = [ { name = "google-auth" }, @@ -1392,6 +1461,7 @@ dependencies = [ dev = [ { name = "build" }, { name = "furo" }, + { name = "hypothesis" }, { name = "itables" }, { name = "l0-python" }, { name = "pytest" }, @@ -1410,6 +1480,7 @@ requires-dist = [ { name = "google-auth" }, { name = "google-cloud-storage" }, { name = "huggingface-hub" }, + { name = "hypothesis", marker = "extra == 'dev'" }, { name = "itables", marker = "extra == 'dev'" }, { name = "l0-python", marker = "extra == 'dev'", specifier = ">=0.4.0" }, { name = "microcalibrate", specifier = ">=0.18.0" }, From c3457445d5f280b50d0b80e6f78778e37ee7d412 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 16:54:57 -0400 Subject: [PATCH 2/8] Scope DWP and OBR welfare targets to the countries DWP covers OBR EFO table 4.9's "DWP social security" lines are DWP's "DWP Social Security (GB)" figures, which leave out Northern Ireland; for disability benefits, Carer's Allowance and Winter Fuel Payment they also leave out Scotland (DWP benefit expenditure and caseload tables, Notes K, L and N). DWP's benefit, benefit cap, UC and two-child-limit statistics cover Great Britain, and its PIP statistics England and Wales. Each of these targets now sets `countries`, so its loss matrix column counts only households there. Child benefit (HMRC) stays UK-wide. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk_data/targets/schema.py | 4 +++ policyengine_uk_data/targets/sources/dwp.py | 34 ++++++++++++++++++++- policyengine_uk_data/targets/sources/obr.py | 27 +++++++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/policyengine_uk_data/targets/schema.py b/policyengine_uk_data/targets/schema.py index 0a678a228..9212ad717 100644 --- a/policyengine_uk_data/targets/schema.py +++ b/policyengine_uk_data/targets/schema.py @@ -22,6 +22,10 @@ class Unit(str, Enum): # DWP statistics cover Great Britain: benefits for Northern Ireland residents # are the Northern Ireland Executive's responsibility. GREAT_BRITAIN = ("ENGLAND", "SCOTLAND", "WALES") +# For benefits whose executive competence passed to the Scottish Government +# (disability benefits, Carer's Allowance, Winter Fuel Payment), DWP's +# statistics cover England and Wales only. +ENGLAND_AND_WALES = ("ENGLAND", "WALES") class Target(BaseModel): diff --git a/policyengine_uk_data/targets/sources/dwp.py b/policyengine_uk_data/targets/sources/dwp.py index b095f055a..2c970b2aa 100644 --- a/policyengine_uk_data/targets/sources/dwp.py +++ b/policyengine_uk_data/targets/sources/dwp.py @@ -5,6 +5,11 @@ children/family type, two-child limit breakdowns, and Scotland UC households with child under 1. +DWP's statistics cover Great Britain: benefits for Northern Ireland +residents are the Northern Ireland Executive's, so every target here sets +``countries``. The PIP statistics cover England and Wales, since Adult +Disability Payment replaced PIP in Scotland. + Sources: - DWP benefit statistics: https://www.gov.uk/government/statistics/dwp-benefit-statistics-february-2026/dwp-benefit-statistics-february-2026 - DWP PIP statistics: https://www.gov.uk/government/statistics/personal-independence-payment-statistics-to-january-2026 @@ -13,7 +18,12 @@ - DWP two-child limit: https://www.gov.uk/government/statistics/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025 """ -from policyengine_uk_data.targets.schema import Target, Unit +from policyengine_uk_data.targets.schema import ( + ENGLAND_AND_WALES, + GREAT_BRITAIN, + Target, + Unit, +) _DWP_BENEFIT_STATS_FEB_2026 = ( @@ -40,6 +50,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2025: 1_283_000}, + countries=ENGLAND_AND_WALES, is_count=True, reference_url=_PIP_STATS_JAN_2026, ) @@ -51,6 +62,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2025: 1_608_000}, + countries=ENGLAND_AND_WALES, is_count=True, reference_url=_PIP_STATS_JAN_2026, ) @@ -69,6 +81,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2025: 999_000}, + countries=GREAT_BRITAIN, is_count=True, reference_url=_DWP_BENEFIT_STATS_FEB_2026, ), @@ -78,6 +91,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2025: 620_000}, + countries=GREAT_BRITAIN, is_count=True, reference_url=_DWP_BENEFIT_STATS_FEB_2026, ), @@ -87,6 +101,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2025: 180_000}, + countries=GREAT_BRITAIN, is_count=True, reference_url=_DWP_BENEFIT_STATS_FEB_2026, ), @@ -96,6 +111,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2025: 71_000}, + countries=GREAT_BRITAIN, is_count=True, reference_url=_DWP_BENEFIT_STATS_FEB_2026, ), @@ -110,6 +126,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2025: 110_637}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://www.gov.uk/government/statistics/benefit-cap-number-of-households-capped-to-november-2025/benefit-cap-number-of-households-capped-to-november-2025", ) @@ -123,6 +140,7 @@ def get_targets() -> list[Target]: # Uses the November 2025 point-in-time cap distribution midpoint by band, # annualized to align with the model's yearly benefit_cap_reduction output. values={2025: 320_866_000}, + countries=GREAT_BRITAIN, reference_url="https://www.gov.uk/government/statistics/benefit-cap-number-of-households-capped-to-november-2025/benefit-cap-number-of-households-capped-to-november-2025", ) ) @@ -135,6 +153,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2025: 14_000}, + countries=("SCOTLAND",), is_count=True, reference_url="https://stat-xplore.dwp.gov.uk/", ) @@ -156,6 +175,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2025: count}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://stat-xplore.dwp.gov.uk/", ) @@ -177,6 +197,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2025: count_k * 1e3}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://stat-xplore.dwp.gov.uk/", ) @@ -194,6 +215,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2025: 6_700_000, 2026: 7_200_000}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://www.gov.uk/government/statistics/universal-credit-quarterly-statistics-29-april-2013-to-12-february-2026/universal-credit-deductions-statistics-march-2025-to-february-2026", ) @@ -207,6 +229,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2026: 453_600}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://www.gov.uk/government/statistics/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025", ) @@ -218,6 +241,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2026: 1_613_980}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://www.gov.uk/government/statistics/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025", ) @@ -229,6 +253,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2026: 580_400}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://www.gov.uk/government/statistics/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025", ) @@ -249,6 +274,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2026: households}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://www.gov.uk/government/statistics/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025", ) @@ -260,6 +286,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2026: children}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://www.gov.uk/government/statistics/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025", ) @@ -274,6 +301,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2026: 62_260}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://www.gov.uk/government/statistics/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025", ), @@ -283,6 +311,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2026: 225_320}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://www.gov.uk/government/statistics/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025", ), @@ -292,6 +321,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2026: 124_560}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://www.gov.uk/government/statistics/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025", ), @@ -301,6 +331,7 @@ def get_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2026: 462_660}, + countries=GREAT_BRITAIN, is_count=True, reference_url="https://www.gov.uk/government/statistics/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025/universal-credit-claimants-statistics-on-the-two-child-limit-policy-april-2025", ), @@ -330,6 +361,7 @@ def _uc_payment_distribution_targets() -> list[Target]: source="dwp", unit=Unit.COUNT, values={2025: float(row.household_count)}, + countries=GREAT_BRITAIN, is_count=True, breakdown_variable="universal_credit", lower_bound=float(lower), diff --git a/policyengine_uk_data/targets/sources/obr.py b/policyengine_uk_data/targets/sources/obr.py index ad4e3cf17..328642e10 100644 --- a/policyengine_uk_data/targets/sources/obr.py +++ b/policyengine_uk_data/targets/sources/obr.py @@ -17,7 +17,8 @@ import openpyxl import requests -from policyengine_uk_data.targets.schema import Target, Unit +from policyengine_uk_data.targets.schema import GREAT_BRITAIN, Target, Unit +from policyengine_uk_data.targets.schema import ENGLAND_AND_WALES from policyengine_uk_data.targets.sources._common import ( HEADERS, load_config, @@ -517,6 +518,28 @@ def read_49(row_num: int) -> dict[int, float]: "jsa", ), } + # Table 4.9 lists DWP's spending under "DWP social security", the figures + # DWP's benefit expenditure and caseload tables (Spring Forecast 2026) + # give as "DWP Social Security (GB)": Northern Ireland's benefits are the + # table's "NI social security" rows. Those tables also leave out Scotland + # for benefits whose executive competence passed to the Scottish + # Government (Notes K, L and N): disability benefits from 2020-21, Carer's + # Allowance from 2018-19 and Winter Fuel Payment from 2024-25. Child + # benefit is HMRC's and covers the UK. + coverage = { + "housing_benefit": GREAT_BRITAIN, + "pip": ENGLAND_AND_WALES, + "esa": GREAT_BRITAIN, + "attendance_allowance": ENGLAND_AND_WALES, + "pension_credit": GREAT_BRITAIN, + "carers_allowance": ENGLAND_AND_WALES, + "statutory_maternity_pay": GREAT_BRITAIN, + "winter_fuel_allowance": ENGLAND_AND_WALES, + "universal_credit_in_cap": GREAT_BRITAIN, + "child_benefit": None, + "state_pension": GREAT_BRITAIN, + "jobseekers_allowance": GREAT_BRITAIN, + } targets = [] # Welfare cap section (rows 6-36) @@ -532,6 +555,7 @@ def read_49(row_num: int) -> dict[int, float]: source="obr", unit=Unit.GBP, values=values, + countries=coverage[name], reference_url=ref, forecast_vintage=vintage, ) @@ -556,6 +580,7 @@ def read_49(row_num: int) -> dict[int, float]: source="obr", unit=Unit.GBP, values=values, + countries=GREAT_BRITAIN, reference_url=ref, forecast_vintage=vintage, ) From a6fd43951db16b2b0f85c3b7f8ee4976c907f8ce Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 17:38:18 -0400 Subject: [PATCH 3/8] List only the OBR welfare lines that are not DWP's GB figures Every line in table 4.9's "DWP social security" blocks is DWP's GB figure, so the coverage map now names only the exceptions (the four benefits devolved in Scotland, and HMRC's child benefit) and defaults to Great Britain. #490, #510 and #530 drop the housing benefit, pension credit and in-cap universal credit lines, which would have left stale entries in a map of every line. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk_data/targets/sources/obr.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/policyengine_uk_data/targets/sources/obr.py b/policyengine_uk_data/targets/sources/obr.py index 328642e10..a7b32b340 100644 --- a/policyengine_uk_data/targets/sources/obr.py +++ b/policyengine_uk_data/targets/sources/obr.py @@ -521,24 +521,17 @@ def read_49(row_num: int) -> dict[int, float]: # Table 4.9 lists DWP's spending under "DWP social security", the figures # DWP's benefit expenditure and caseload tables (Spring Forecast 2026) # give as "DWP Social Security (GB)": Northern Ireland's benefits are the - # table's "NI social security" rows. Those tables also leave out Scotland - # for benefits whose executive competence passed to the Scottish - # Government (Notes K, L and N): disability benefits from 2020-21, Carer's - # Allowance from 2018-19 and Winter Fuel Payment from 2024-25. Child - # benefit is HMRC's and covers the UK. - coverage = { - "housing_benefit": GREAT_BRITAIN, + # table's "NI social security" rows. Those tables leave out Scotland for + # benefits whose executive competence passed to the Scottish Government + # (Notes K, L and N): Carer's Allowance from 2018-19, disability benefits + # from 2020-21 and Winter Fuel Payment from 2024-25. Child benefit is + # HMRC's and covers the UK. Every other line here is DWP's GB figure. + countries = { "pip": ENGLAND_AND_WALES, - "esa": GREAT_BRITAIN, "attendance_allowance": ENGLAND_AND_WALES, - "pension_credit": GREAT_BRITAIN, "carers_allowance": ENGLAND_AND_WALES, - "statutory_maternity_pay": GREAT_BRITAIN, "winter_fuel_allowance": ENGLAND_AND_WALES, - "universal_credit_in_cap": GREAT_BRITAIN, "child_benefit": None, - "state_pension": GREAT_BRITAIN, - "jobseekers_allowance": GREAT_BRITAIN, } targets = [] @@ -555,7 +548,7 @@ def read_49(row_num: int) -> dict[int, float]: source="obr", unit=Unit.GBP, values=values, - countries=coverage[name], + countries=countries.get(name, GREAT_BRITAIN), reference_url=ref, forecast_vintage=vintage, ) From b1ef095748dd87789af54d112cb9089afb05e2b1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 17:38:18 -0400 Subject: [PATCH 4/8] Test the countries calibration targets cover Property tests for restrict_to_countries (in-scope households kept, others zeroed; complementary country sets add back to the column; restricting twice restricts to the common countries; None is the identity), the loss matrix end to end, table 4.9's block structure against each OBR welfare target's coverage, and that every DWP target declares its countries. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_target_countries.py | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 policyengine_uk_data/tests/test_target_countries.py diff --git a/policyengine_uk_data/tests/test_target_countries.py b/policyengine_uk_data/tests/test_target_countries.py new file mode 100644 index 000000000..56fd06b67 --- /dev/null +++ b/policyengine_uk_data/tests/test_target_countries.py @@ -0,0 +1,287 @@ +"""Tests for the countries a calibration target covers. + +DWP's statistics cover Great Britain, and England and Wales for the benefits +devolved to the Scottish Government. A target that sets ``countries`` counts +only households in those countries in its loss matrix column. +""" + +from itertools import combinations +from types import SimpleNamespace + +import numpy as np +import openpyxl +import pandas as pd +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st +from policyengine_uk.variables.household.demographic.country import Country + +from policyengine_uk_data.storage import STORAGE_FOLDER +from policyengine_uk_data.targets import build_loss_matrix +from policyengine_uk_data.targets.build_loss_matrix import restrict_to_countries +from policyengine_uk_data.targets.registry import discover_source_modules +from policyengine_uk_data.targets.schema import ( + ENGLAND_AND_WALES, + GREAT_BRITAIN, + GeographicLevel, + Target, + Unit, +) +from policyengine_uk_data.targets.sources import dwp, obr + +COUNTRIES = tuple(country.name for country in Country) +YEAR_COLUMNS = dict(zip("CDEFGHI", range(2024, 2031))) +# Benefits whose executive competence passed to the Scottish Government, so +# DWP reports them for England and Wales (BECT Notes K, L and N). +DEVOLVED_IN_SCOTLAND = { + "pip", + "attendance_allowance", + "carers_allowance", + "winter_fuel_allowance", +} + + +def test_coverage_constants_are_values_of_the_country_variable(): + # A misspelt country would silently zero a column. + assert set(GREAT_BRITAIN) == {"ENGLAND", "SCOTLAND", "WALES"} + assert set(ENGLAND_AND_WALES) == {"ENGLAND", "WALES"} + assert set(GREAT_BRITAIN) <= set(COUNTRIES) + + +# ── restrict_to_countries ────────────────────────────────────────────── + + +@st.composite +def _households(draw): + """A household column and each household's country, of equal length.""" + n = draw(st.integers(0, 30)) + country = draw(st.lists(st.sampled_from(COUNTRIES), min_size=n, max_size=n)) + column = draw( + st.lists(st.floats(-1e12, 1e12, allow_nan=False), min_size=n, max_size=n) + ) + return np.array(column), np.array(country, dtype=object) + + +_country_sets = st.sets(st.sampled_from(COUNTRIES)).map(tuple) + + +@settings(max_examples=300, deadline=None) +@given(_households(), _country_sets) +def test_restriction_keeps_households_in_scope_and_zeroes_the_rest(case, countries): + column, country = case + restricted = restrict_to_countries(column, country, countries) + in_scope = np.array([c in countries for c in country], dtype=bool) + np.testing.assert_array_equal(restricted[in_scope], column[in_scope]) + assert (restricted[~in_scope] == 0).all() + + +@settings(max_examples=300, deadline=None) +@given(_households(), _country_sets) +def test_restrictions_to_complementary_countries_add_up_to_the_column(case, countries): + """Conservation: splitting the UK into two sets of countries loses and + double counts nothing, so a GB column plus its Northern Ireland (and + unknown-country) remainder is the UK column.""" + column, country = case + rest = tuple(c for c in COUNTRIES if c not in countries) + np.testing.assert_array_equal( + restrict_to_countries(column, country, countries) + + restrict_to_countries(column, country, rest), + column, + ) + + +@settings(max_examples=300, deadline=None) +@given(_households(), _country_sets, _country_sets) +def test_restricting_twice_restricts_to_the_common_countries(case, first, second): + column, country = case + common = tuple(c for c in first if c in second) + np.testing.assert_array_equal( + restrict_to_countries( + restrict_to_countries(column, country, first), country, second + ), + restrict_to_countries(column, country, common), + ) + + +@settings(max_examples=100, deadline=None) +@given(_households()) +def test_no_countries_means_the_whole_uk(case): + column, country = case + assert restrict_to_countries(column, country, None) is column + + +def test_target_matrix_counts_only_households_in_a_targets_countries(monkeypatch): + """Through create_target_matrix itself, with one household per country.""" + import policyengine_uk + + country = pd.Series(["ENGLAND", "SCOTLAND", "WALES", "NORTHERN_IRELAND"]) + column = np.array([1.0, 2.0, 4.0, 8.0]) + + def target(name, countries): + return Target( + name=name, + variable="universal_credit", + source="test", + unit=Unit.GBP, + values={2025: 1.0}, + countries=countries, + custom_compute=lambda ctx, target, year: column, + ) + + targets = [ + target("uk", None), + target("gb", GREAT_BRITAIN), + target("ew", ENGLAND_AND_WALES), + ] + + class FakeMicrosimulation: + def __init__(self, dataset=None, reform=None): + pass + + def calculate(self, variable, *args, **kwargs): + assert variable == "country" + return country + + monkeypatch.setattr(policyengine_uk, "Microsimulation", FakeMicrosimulation) + monkeypatch.setattr( + build_loss_matrix, + "get_all_targets", + lambda geographic_level=None: ( + targets if geographic_level == GeographicLevel.NATIONAL else [] + ), + ) + + matrix, values = build_loss_matrix.create_target_matrix( + SimpleNamespace(time_period="2025"), time_period="2025" + ) + np.testing.assert_array_equal(matrix["uk"], [1, 2, 4, 8]) + np.testing.assert_array_equal(matrix["gb"], [1, 2, 4, 0]) + np.testing.assert_array_equal(matrix["ew"], [1, 0, 4, 0]) + assert list(values) == [1.0, 1.0, 1.0] + + +# ── OBR table 4.9 ────────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def table_4_9(): + wb = openpyxl.load_workbook(STORAGE_FOLDER / "obr_efo" / "efo_expenditure.xlsx") + return wb, wb["4.9"] + + +def _label(ws, row) -> str: + return str(ws[f"B{row}"].value or "").strip() + + +def _dwp_blocks(ws) -> list[range]: + """Table 4.9's "DWP social security" blocks, inside and outside the + welfare cap: from each block's total to the "Other DWP" line closing it.""" + blocks, start = [], None + for row in range(1, ws.max_row + 1): + if _label(ws, row).startswith("DWP social security"): + start = row + elif start is not None and _label(ws, row).startswith("Other DWP"): + blocks.append(range(start, row + 1)) + start = None + return blocks + + +def _source_rows(ws, target) -> list[tuple[int, ...]]: + """The table rows a target's values come from: one row, or two summed.""" + + def matches(rows): + return all( + sum(ws[f"{column}{row}"].value for row in rows) * 1e9 + == pytest.approx(target.values[year], rel=1e-12) + for column, year in YEAR_COLUMNS.items() + if year in target.values + ) + + numeric = [ + row + for row in range(1, ws.max_row + 1) + if all( + isinstance(ws[f"{column}{row}"].value, (int, float)) + for column in YEAR_COLUMNS + ) + ] + singles = [(row,) for row in numeric if matches((row,))] + return singles or [pair for pair in combinations(numeric, 2) if matches(pair)] + + +def test_dwp_blocks_hold_exactly_the_dwp_social_security_lines(table_4_9): + """Each block's lines add up to its "DWP social security" total in every + year, so the blocks hold DWP's lines and nothing else. Northern Ireland's + benefits are separate "NI social security" rows outside the blocks.""" + _, ws = table_4_9 + blocks = _dwp_blocks(ws) + assert len(blocks) == 2 + for block in blocks: + total_row, lines = block[0], block[1:] + for column in YEAR_COLUMNS: + values = [ws[f"{column}{row}"].value for row in lines] + numbers = [v for v in values if isinstance(v, (int, float))] + # "*" marks a line under £0.1bn. + tolerance = 0.1 * values.count("*") + 0.005 + assert sum(numbers) == pytest.approx( + ws[f"{column}{total_row}"].value, abs=tolerance + ) + ni_rows = [ + row + for row in range(1, ws.max_row + 1) + if _label(ws, row).startswith("NI social security") + ] + assert len(ni_rows) == 2 + assert not any(row in block for row in ni_rows for block in blocks) + + +def test_welfare_targets_cover_the_countries_of_their_table_block(table_4_9): + """Lines in a "DWP social security" block cover Great Britain, or England + and Wales for benefits devolved to Scotland; lines outside one (child + benefit, HMRC's) cover the UK.""" + wb, ws = table_4_9 + blocks = _dwp_blocks(ws) + targets = obr._parse_welfare(wb) + assert {"obr/state_pension", "obr/child_benefit", "obr/pip"} <= { + t.name for t in targets + } + for target in targets: + sources = _source_rows(ws, target) + assert len(sources) == 1, (target.name, sources) + in_dwp_block = [any(row in block for block in blocks) for row in sources[0]] + if target.countries is None: + assert not any(in_dwp_block), target.name + continue + assert all(in_dwp_block), target.name + expected = ( + ENGLAND_AND_WALES + if target.variable in DEVOLVED_IN_SCOTLAND + else GREAT_BRITAIN + ) + assert target.countries == expected, target.name + + +# ── DWP statistics ───────────────────────────────────────────────────── + + +def test_every_dwp_target_declares_the_countries_it_covers(): + """No DWP statistic covers Northern Ireland, so a DWP target without + ``countries`` would count Northern Ireland households.""" + modules = [ + module + for module in discover_source_modules() + if module.__name__.rsplit(".", 1)[-1].startswith("dwp") + ] + targets = [target for module in modules for target in module.get_targets()] + assert len(targets) > 100 + undeclared = [t.name for t in targets if t.countries is None] + assert undeclared == [] + assert all(set(t.countries) <= set(GREAT_BRITAIN) for t in targets) + + +def test_pip_claimant_targets_cover_england_and_wales(): + """Adult Disability Payment replaced PIP in Scotland; DWP's PIP + statistics cover England and Wales.""" + pip = [t for t in dwp.get_targets() if t.name.startswith("dwp/pip_")] + assert len(pip) == 2 + assert all(t.countries == ENGLAND_AND_WALES for t in pip) From 30bd26a6308b504756fc91b27e3f8583b145892b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 17:44:04 -0400 Subject: [PATCH 5/8] Check country coverage against a real simulation's country output The loss-matrix test now runs create_target_matrix on a policyengine-uk simulation with one household in each country, so GREAT_BRITAIN and ENGLAND_AND_WALES are checked against what the `country` variable returns, not against a stub. obr.py imports the schema names in one statement: the split import did not avoid the textual conflict with #530 it was there for. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk_data/targets/sources/obr.py | 8 +++- .../tests/test_target_countries.py | 42 ++++++++++--------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/policyengine_uk_data/targets/sources/obr.py b/policyengine_uk_data/targets/sources/obr.py index a7b32b340..2f31cfb17 100644 --- a/policyengine_uk_data/targets/sources/obr.py +++ b/policyengine_uk_data/targets/sources/obr.py @@ -17,8 +17,12 @@ import openpyxl import requests -from policyengine_uk_data.targets.schema import GREAT_BRITAIN, Target, Unit -from policyengine_uk_data.targets.schema import ENGLAND_AND_WALES +from policyengine_uk_data.targets.schema import ( + ENGLAND_AND_WALES, + GREAT_BRITAIN, + Target, + Unit, +) from policyengine_uk_data.targets.sources._common import ( HEADERS, load_config, diff --git a/policyengine_uk_data/tests/test_target_countries.py b/policyengine_uk_data/tests/test_target_countries.py index 56fd06b67..b3834c775 100644 --- a/policyengine_uk_data/tests/test_target_countries.py +++ b/policyengine_uk_data/tests/test_target_countries.py @@ -10,7 +10,6 @@ import numpy as np import openpyxl -import pandas as pd import pytest from hypothesis import given, settings from hypothesis import strategies as st @@ -111,11 +110,19 @@ def test_no_countries_means_the_whole_uk(case): def test_target_matrix_counts_only_households_in_a_targets_countries(monkeypatch): - """Through create_target_matrix itself, with one household per country.""" + """Through create_target_matrix, on a real simulation with one household in + each country, so the coverage constants meet the `country` variable's own + output. Each country's household holds a distinct power of two, so a + column's total says which countries it counted.""" import policyengine_uk - country = pd.Series(["ENGLAND", "SCOTLAND", "WALES", "NORTHERN_IRELAND"]) - column = np.array([1.0, 2.0, 4.0, 8.0]) + amount = {"ENGLAND": 1.0, "SCOTLAND": 2.0, "WALES": 4.0, "NORTHERN_IRELAND": 8.0} + situation = { + "people": {c: {"age": {2025: 40}} for c in amount}, + "benunits": {c: {"members": [c]} for c in amount}, + "households": {c: {"members": [c], "country": {2025: c}} for c in amount}, + } + microsimulation = policyengine_uk.Microsimulation def target(name, countries): return Target( @@ -125,24 +132,22 @@ def target(name, countries): unit=Unit.GBP, values={2025: 1.0}, countries=countries, - custom_compute=lambda ctx, target, year: column, + custom_compute=lambda ctx, target, year: np.array( + [amount[c] for c in ctx.country] + ), ) targets = [ target("uk", None), target("gb", GREAT_BRITAIN), target("ew", ENGLAND_AND_WALES), + target("ni", ("NORTHERN_IRELAND",)), ] - - class FakeMicrosimulation: - def __init__(self, dataset=None, reform=None): - pass - - def calculate(self, variable, *args, **kwargs): - assert variable == "country" - return country - - monkeypatch.setattr(policyengine_uk, "Microsimulation", FakeMicrosimulation) + monkeypatch.setattr( + policyengine_uk, + "Microsimulation", + lambda dataset=None, reform=None: microsimulation(situation=situation), + ) monkeypatch.setattr( build_loss_matrix, "get_all_targets", @@ -154,10 +159,9 @@ def calculate(self, variable, *args, **kwargs): matrix, values = build_loss_matrix.create_target_matrix( SimpleNamespace(time_period="2025"), time_period="2025" ) - np.testing.assert_array_equal(matrix["uk"], [1, 2, 4, 8]) - np.testing.assert_array_equal(matrix["gb"], [1, 2, 4, 0]) - np.testing.assert_array_equal(matrix["ew"], [1, 0, 4, 0]) - assert list(values) == [1.0, 1.0, 1.0] + assert list(matrix.columns) == ["uk", "gb", "ew", "ni"] + assert matrix.sum().to_dict() == {"uk": 15, "gb": 7, "ew": 5, "ni": 8} + assert list(values) == [1.0] * 4 # ── OBR table 4.9 ────────────────────────────────────────────────────── From f0a5dbc66f2562f6f8bd6076142c1ac4efcbde3b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 5 Oct 2026 19:29:42 -0400 Subject: [PATCH 6/8] Add a changelog fragment for scoping DWP and OBR welfare targets Co-Authored-By: Claude Opus 5.5 --- changelog.d/gb-scope-dwp-targets.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/gb-scope-dwp-targets.fixed.md diff --git a/changelog.d/gb-scope-dwp-targets.fixed.md b/changelog.d/gb-scope-dwp-targets.fixed.md new file mode 100644 index 000000000..18d609f2f --- /dev/null +++ b/changelog.d/gb-scope-dwp-targets.fixed.md @@ -0,0 +1 @@ +Calibrate DWP and OBR welfare targets only on households in the countries their source covers. DWP's statistics and the "DWP social security" lines of OBR EFO table 4.9 cover Great Britain, as Northern Ireland runs its own social security, but their loss-matrix columns summed over every UK household. These targets now set `countries` to Great Britain. The OBR lines for disability living allowance and PIP, attendance allowance, carer's allowance and winter fuel payment, whose executive competence passed to the Scottish Government, cover England and Wales, as do DWP's PIP claimant counts. Child benefit (HMRC) stays UK-wide. From 33b9f236102b8efe116f7e137fd1cbc125e3da74 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 14:40:01 -0400 Subject: [PATCH 7/8] Refresh lockfile project version to post-batch 1.58.0 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 5ae821352..dce9c8285 100644 --- a/uv.lock +++ b/uv.lock @@ -1433,7 +1433,7 @@ wheels = [ [[package]] name = "policyengine-uk-data" -version = "1.57.4" +version = "1.58.0" source = { editable = "." } dependencies = [ { name = "google-auth" }, From ec312d6fcc7d9b8222445c44bd051397dbaf8e75 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 14:45:26 -0400 Subject: [PATCH 8/8] Cover post-batch target replacements in country-scoping invariants --- .../tests/test_target_countries.py | 132 +++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/policyengine_uk_data/tests/test_target_countries.py b/policyengine_uk_data/tests/test_target_countries.py index b3834c775..b7a081be8 100644 --- a/policyengine_uk_data/tests/test_target_countries.py +++ b/policyengine_uk_data/tests/test_target_countries.py @@ -10,6 +10,7 @@ import numpy as np import openpyxl +import pandas as pd import pytest from hypothesis import given, settings from hypothesis import strategies as st @@ -26,7 +27,14 @@ Target, Unit, ) -from policyengine_uk_data.targets.sources import dwp, obr +from policyengine_uk_data.targets.sources import ( + dwp, + dwp_housing_benefit, + dwp_pension_credit, + hmrc_salary_sacrifice, + obr, + ons_labour_market, +) COUNTRIES = tuple(country.name for country in Country) YEAR_COLUMNS = dict(zip("CDEFGHI", range(2024, 2031))) @@ -289,3 +297,125 @@ def test_pip_claimant_targets_cover_england_and_wales(): pip = [t for t in dwp.get_targets() if t.name.startswith("dwp/pip_")] assert len(pip) == 2 assert all(t.countries == ENGLAND_AND_WALES for t in pip) + + +# ── Targets introduced or replaced in the 1.58.0 batch ────────────────── + + +@pytest.mark.parametrize( + "get_targets, names", + [ + ( + dwp_housing_benefit.get_targets, + { + "dwp/housing_benefit/over_pension_credit_age", + "dwp/housing_benefit/over_pension_credit_age_claims", + "dwp/housing_benefit/under_pension_credit_age_general_needs", + "dwp/housing_benefit/under_pension_credit_age_general_needs_claims", + }, + ), + ( + dwp_housing_benefit.build_targets, + { + "dwp/housing_benefit/over_pension_credit_age", + "dwp/housing_benefit/over_pension_credit_age_claims", + "dwp/housing_benefit/under_pension_credit_age", + "dwp/housing_benefit/under_pension_credit_age_claims", + "dwp/housing_benefit/under_pension_credit_age_general_needs", + "dwp/housing_benefit/under_pension_credit_age_general_needs_claims", + }, + ), + ( + dwp_pension_credit.get_targets, + {"dwp/pension_credit", "dwp/pension_credit_claims"}, + ), + ], + ids=[ + "housing-benefit-calibration", + "housing-benefit-diagnostics", + "pension-credit", + ], +) +def test_batch_dwp_replacement_targets_cover_exactly_great_britain(get_targets, names): + """#490 Housing Benefit and #510 Pension Credit use DWP's GB tables. + + Equality catches losing Scotland or Wales as well as adding Northern + Ireland; a GB-subset check would miss the former. + """ + targets = get_targets() + assert len(targets) == len(names) + assert {t.name: t.countries for t in targets} == dict.fromkeys(names, GREAT_BRITAIN) + + +def test_batch_obr_replacements_preserve_one_gb_universal_credit_target(monkeypatch): + """#530 combines both DWP GB UC rows; #490, #510 and #533 replace + obsolete OBR targets with DWP or HMRC targets rather than duplicating them. + """ + monkeypatch.setattr(obr, "_download_workbook", obr._fallback_workbook) + targets = obr.get_targets() + names = [t.name for t in targets] + assert len(names) == len(set(names)) + uc = [t for t in targets if t.variable == "universal_credit"] + assert [(t.name, t.countries) for t in uc] == [ + ("obr/universal_credit", GREAT_BRITAIN) + ] + assert not { + "obr/housing_benefit", + "obr/pension_credit", + "obr/universal_credit_in_cap", + "obr/universal_credit_outside_cap", + "obr/salary_sacrifice_employee_ni_relief", + "obr/salary_sacrifice_employer_ni_relief", + } & set(names) + + +def test_lfs_employment_targets_keep_the_whole_uk_in_scope(): + """#529's MGRN and MGRQ source series explicitly cover the UK.""" + targets = ons_labour_market.get_targets() + assert len(targets) == 2 + assert {t.name: t.countries for t in targets} == { + "ons/lfs_employees": None, + "ons/lfs_self_employed": None, + } + + +def test_hmrc_salary_sacrifice_replacements_preserve_mains_country_scope(monkeypatch): + """#533's HMRC targets keep main's unrestricted country scope. + + The committed table does not give a country field, so this refresh + leaves main's scope intact. It must not inherit a DWP GB restriction. + """ + monkeypatch.setattr( + hmrc_salary_sacrifice, + "_read_table", + lambda url: pd.read_csv( + hmrc_salary_sacrifice.FALLBACK_CSV, dtype=str, encoding="utf-8-sig" + ), + ) + targets = hmrc_salary_sacrifice.get_targets() + names = { + "hmrc/salary_sacrifice_it_relief_basic_rate", + "hmrc/salary_sacrifice_it_relief_higher_rate", + "hmrc/salary_sacrifice_it_relief_additional_rate", + "hmrc/salary_sacrifice_employee_nics_relief", + "hmrc/salary_sacrifice_employer_nics_relief", + "hmrc/salary_sacrifice_contributions", + } + assert len(targets) == len(names) + assert {t.name: t.countries for t in targets} == dict.fromkeys(names) + + +def test_uc_payment_distribution_targets_all_cover_great_britain(): + """The batch's repaired open top bands share the extract's GB scope.""" + targets = dwp._uc_payment_distribution_targets() + assert targets + assert all(t.countries == GREAT_BRITAIN for t in targets) + assert {t.name for t in targets if not np.isfinite(t.upper_bound)} == { + f"dwp/uc_payment_dist/{family_type}_annual_payment_30_000_to_inf" + for family_type in ( + "SINGLE", + "LONE_PARENT", + "COUPLE_NO_CHILDREN", + "COUPLE_WITH_CHILDREN", + ) + }