From 44d26647dc164c641ee92d856136f72348e923a1 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Fri, 23 May 2025 15:20:12 +0100 Subject: [PATCH 01/36] Add `versions` argument to `Simulation` Fixes #147 --- policyengine/constants.py | 25 +++++++++--------- policyengine/simulation.py | 39 +++++++++++++++++++++++++++-- policyengine/utils/data_download.py | 28 +++++++++++++++++++-- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/policyengine/constants.py b/policyengine/constants.py index 43351271..0ed7f07c 100644 --- a/policyengine/constants.py +++ b/policyengine/constants.py @@ -2,16 +2,10 @@ from policyengine_core.data import Dataset from policyengine.utils.data_download import download +from typing import Tuple -# Datasets -ENHANCED_FRS = "hf://policyengine/policyengine-uk-data/enhanced_frs_2022_23.h5" -FRS = "hf://policyengine/policyengine-uk-data/frs_2022_23.h5" -ENHANCED_CPS = "hf://policyengine/policyengine-us-data/enhanced_cps_2024.h5" -CPS = "hf://policyengine/policyengine-us-data/cps_2023.h5" -POOLED_CPS = "hf://policyengine/policyengine-us-data/pooled_3_year_cps_2023.h5" - -def get_default_dataset(country: str, region: str): +def get_default_dataset(country: str, region: str) -> Tuple[Dataset, str]: if country == "uk": data_file = download( filepath="enhanced_frs_2022_23.h5", @@ -21,21 +15,26 @@ def get_default_dataset(country: str, region: str): time_period = None elif country == "us": if region is not None and region != "us": - data_file = download( + data_file, version = download( filepath="pooled_3_year_cps_2023.h5", huggingface_repo="policyengine-us-data", gcs_bucket="policyengine-us-data", + return_version=True, ) time_period = 2023 else: - data_file = download( + data_file, version = download( filepath="cps_2023.h5", huggingface_repo="policyengine-us-data", gcs_bucket="policyengine-us-data", + return_version=True, ) time_period = 2023 - return Dataset.from_file( - file_path=data_file, - time_period=time_period, + return ( + Dataset.from_file( + file_path=data_file, + time_period=time_period, + ), + version, ) diff --git a/policyengine/simulation.py b/policyengine/simulation.py index aa5858a9..654222b1 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -18,6 +18,7 @@ Simulation as UKSimulation, Microsimulation as UKMicrosimulation, ) +from importlib import metadata import h5py from pathlib import Path import pandas as pd @@ -62,6 +63,14 @@ class SimulationOptions(BaseModel): False, description="Whether to include tax-benefit cliffs in the simulation analyses. If True, cliffs will be included.", ) + package_versions: Dict[str, str] | None = Field( + None, + description="The versions of the packages used in the simulation. If not provided, the current package versions will be used. If provided, this package will throw an error if the package versions do not match.", + ) + data_versions: Dict[str, str] | None = Field( + None, + description="The versions of the data used in the simulation. If not provided, the current data versions will be used. If provided, this package will throw an error if the data versions do not match.", + ) class Simulation: @@ -73,6 +82,7 @@ class Simulation: """The baseline tax-benefit simulation.""" reform_simulation: CountrySimulation | None = None """The reform tax-benefit simulation.""" + data_versions: Dict[str, str] | None = None def __init__(self, **options: SimulationOptions): self.options = SimulationOptions(**options) @@ -113,8 +123,9 @@ def _add_output_functions(self): ) def _set_data(self): + self.data_versions = {} if self.options.data is None: - self.options.data = get_default_dataset( + self.options.data, version = get_default_dataset( country=self.options.country, region=self.options.region, ) @@ -135,13 +146,17 @@ def _set_data(self): -1 ].split("/", 2) - file_path = download( + file_path, version = download( filepath=filename, huggingface_org=hf_org, huggingface_repo=hf_repo, gcs_bucket=bucket, + return_version=True, ) filename = str(Path(file_path)) + else: + # If it's a local file, we can't infer the version. + version = None if "cps_2023" in filename: time_period = 2023 else: @@ -150,6 +165,8 @@ def _set_data(self): filename, time_period=time_period ) + self.data_versions[self.options.data.file_path.name] = version + def _initialise_simulations(self): self.baseline_simulation = self._initialise_simulation( scope=self.options.scope, @@ -327,3 +344,21 @@ def _apply_region_to_simulation( ) return simulation + + def check_package_versions(self) -> None: + """ + Check the package versions of the simulation against the current package versions. + """ + if self.options.package_versions is not None: + for package, version in self.options.package_versions.items(): + try: + installed_version = metadata.version(package) + except metadata.PackageNotFoundError: + raise ValueError(f"Package {package} not found.") + if installed_version != version: + raise ValueError( + f"Package {package} version {installed_version} does not match expected version {version}." + ) + + def check_data_versions(self) -> None: + pass diff --git a/policyengine/utils/data_download.py b/policyengine/utils/data_download.py index 5b0f776a..b662ab81 100644 --- a/policyengine/utils/data_download.py +++ b/policyengine/utils/data_download.py @@ -4,6 +4,8 @@ from policyengine.utils.huggingface import download_from_hf from policyengine.utils.google_cloud_bucket import download_file_from_gcs from pydantic import BaseModel +import json +from typing import Tuple class DataFile(BaseModel): @@ -18,7 +20,8 @@ def download( huggingface_repo: str = None, gcs_bucket: str = None, huggingface_org: str = "policyengine", -): + return_version: bool = False, +) -> str | Tuple[str, str]: data_file = DataFile( filepath=filepath, huggingface_org=huggingface_org, @@ -31,12 +34,24 @@ def download( if data_file.huggingface_repo is not None: logging.info("Using Hugging Face for download.") try: - return download_from_hf( + data = download_from_hf( repo=data_file.huggingface_org + "/" + data_file.huggingface_repo, repo_filename=data_file.filepath, ) + if return_version: + version_file = download_from_hf( + repo=data_file.huggingface_org + + "/" + + data_file.huggingface_repo, + repo_filename="version.json", + return_version=True, + ) + with open(version_file, "r") as f: + version = json.load(f).get("version") + return data, version + return data except: logging.info("Failed to download from Hugging Face.") @@ -47,6 +62,15 @@ def download( file_name=filepath, destination_path=filepath, ) + if return_version: + version_file = download_file_from_gcs( + bucket_name=data_file.gcs_bucket, + file_name="version.json", + destination_path="version.json", + ) + with open(version_file, "r") as f: + version = json.load(f).get("version") + return filepath, version return filepath raise ValueError( From 7800d5869ae2ca038e3f61b0670b5d69bd1ffd53 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Fri, 23 May 2025 15:20:50 +0100 Subject: [PATCH 02/36] Add actual data version check --- policyengine/simulation.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/policyengine/simulation.py b/policyengine/simulation.py index 654222b1..f1811503 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -361,4 +361,16 @@ def check_package_versions(self) -> None: ) def check_data_versions(self) -> None: - pass + """ + Check the data versions of the simulation against the current data versions. + """ + if self.options.data_versions is not None: + for file, version in self.options.data_versions.items(): + if file not in self.data_versions: + raise ValueError( + f"Data file {file} not found in simulation." + ) + if self.data_versions[file] != version: + raise ValueError( + f"Data file {file} version {self.data_versions[file]} does not match expected version {version}." + ) From 0bb9348d9f3b2e3b498c58497b83e3642a368993 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Fri, 23 May 2025 15:24:05 +0100 Subject: [PATCH 03/36] Add tests --- changelog_entry.yaml | 4 ++++ tests/country/test_uk.py | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index e69de29b..c500fbe0 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -0,0 +1,4 @@ +- bump: minor + changes: + added: + - Error handling for data and package version mismatches. diff --git a/tests/country/test_uk.py b/tests/country/test_uk.py index 6f083aaa..e47877e4 100644 --- a/tests/country/test_uk.py +++ b/tests/country/test_uk.py @@ -21,3 +21,45 @@ def test_uk_macro_comparison(): ) sim.calculate_economy_comparison() + + +def test_uk_macro_bad_package_versions_fail(): + from policyengine import Simulation + + try: + sim = Simulation( + scope="macro", + country="uk", + reform={ + "gov.hmrc.income_tax.allowances.personal_allowance.amount": 15_000, + }, + package_versions={ + "policyengine-uk": "-1.0.0", + }, + ) + raise ValueError( + "Simulation should have failed with a bad package version." + ) + except: + pass + + +def test_uk_macro_bad_data_versions_fail(): + from policyengine import Simulation + + try: + sim = Simulation( + scope="macro", + country="uk", + reform={ + "gov.hmrc.income_tax.allowances.personal_allowance.amount": 15_000, + }, + data_versions={ + "enhanced_frs_2022_23.h5": "-1.0.0", + }, + ) + raise ValueError( + "Simulation should have failed with a bad data version." + ) + except: + pass From a078b2e07d586a27f8ba1ccd88b3a33c50dfe189 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Fri, 23 May 2025 15:25:32 +0100 Subject: [PATCH 04/36] Add Google auth --- .github/workflows/code_changes.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/code_changes.yaml b/.github/workflows/code_changes.yaml index 7c7714ee..c0ae5b67 100644 --- a/.github/workflows/code_changes.yaml +++ b/.github/workflows/code_changes.yaml @@ -31,6 +31,10 @@ jobs: uses: actions/setup-python@v2 with: python-version: '3.11' + - uses: "google-github-actions/auth@v2" + with: + workload_identity_provider: "projects/322898545428/locations/global/workloadIdentityPools/policyengine-research-id-pool/providers/prod-github-provider" + service_account: "policyengine-research@policyengine-research.iam.gserviceaccount.com" - name: Install package run: uv pip install .[dev] --system From cb93042c58882d6987376de3aefa2ed9f1bc51c2 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Fri, 23 May 2025 15:30:54 +0100 Subject: [PATCH 05/36] Add perms --- .github/workflows/code_changes.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/code_changes.yaml b/.github/workflows/code_changes.yaml index c0ae5b67..b45fed1e 100644 --- a/.github/workflows/code_changes.yaml +++ b/.github/workflows/code_changes.yaml @@ -21,6 +21,9 @@ jobs: args: ". -l 79 --check" Test: runs-on: ubuntu-latest + permissions: + contents: "read" + id-token: "write" steps: - name: Checkout repo uses: actions/checkout@v2 From 67754f618f503b15f2563d5b212bd54ed00c8eef Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Fri, 23 May 2025 15:58:59 +0100 Subject: [PATCH 06/36] Fix bug --- policyengine/constants.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/policyengine/constants.py b/policyengine/constants.py index 0ed7f07c..1f18e8c6 100644 --- a/policyengine/constants.py +++ b/policyengine/constants.py @@ -7,10 +7,11 @@ def get_default_dataset(country: str, region: str) -> Tuple[Dataset, str]: if country == "uk": - data_file = download( + data_file, version = download( filepath="enhanced_frs_2022_23.h5", huggingface_repo="policyengine-uk-data", gcs_bucket="policyengine-uk-data-private", + return_version=True, ) time_period = None elif country == "us": From 686dc61268f503727ada7cfad5cd9165a79265d9 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Fri, 23 May 2025 16:01:54 +0100 Subject: [PATCH 07/36] Fix bug --- policyengine/utils/data_download.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine/utils/data_download.py b/policyengine/utils/data_download.py index b662ab81..10629156 100644 --- a/policyengine/utils/data_download.py +++ b/policyengine/utils/data_download.py @@ -63,12 +63,12 @@ def download( destination_path=filepath, ) if return_version: - version_file = download_file_from_gcs( + download_file_from_gcs( bucket_name=data_file.gcs_bucket, file_name="version.json", destination_path="version.json", ) - with open(version_file, "r") as f: + with open("version.json", "r") as f: version = json.load(f).get("version") return filepath, version return filepath From fcf8489390361e4799653293bfe5cb11707553d4 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Fri, 23 May 2025 16:07:26 +0100 Subject: [PATCH 08/36] Add permissions --- .github/workflows/publish_documentation.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/publish_documentation.yaml b/.github/workflows/publish_documentation.yaml index 3196d82c..a739ac38 100644 --- a/.github/workflows/publish_documentation.yaml +++ b/.github/workflows/publish_documentation.yaml @@ -7,6 +7,9 @@ on: jobs: Publish: + permissions: + contents: "read" + id-token: "write" runs-on: ubuntu-latest steps: - name: Checkout repo @@ -15,6 +18,10 @@ jobs: uses: actions/setup-python@v5 with: python-version: 3.12 + - uses: "google-github-actions/auth@v2" + with: + workload_identity_provider: "projects/322898545428/locations/global/workloadIdentityPools/policyengine-research-id-pool/providers/prod-github-provider" + service_account: "policyengine-research@policyengine-research.iam.gserviceaccount.com" - name: Publish a git tag run: ".github/publish-git-tag.sh || true" - name: Install package From 2180465095d31de78c3d39ab7018203c030748c0 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 12:26:19 +0100 Subject: [PATCH 09/36] Begin removal of HF code and GCS versioning --- policyengine/constants.py | 3 - policyengine/utils/data_download.py | 76 +++++------------------ policyengine/utils/google_cloud_bucket.py | 20 +++++- 3 files changed, 32 insertions(+), 67 deletions(-) diff --git a/policyengine/constants.py b/policyengine/constants.py index 1f18e8c6..7fbc5e72 100644 --- a/policyengine/constants.py +++ b/policyengine/constants.py @@ -9,7 +9,6 @@ def get_default_dataset(country: str, region: str) -> Tuple[Dataset, str]: if country == "uk": data_file, version = download( filepath="enhanced_frs_2022_23.h5", - huggingface_repo="policyengine-uk-data", gcs_bucket="policyengine-uk-data-private", return_version=True, ) @@ -18,7 +17,6 @@ def get_default_dataset(country: str, region: str) -> Tuple[Dataset, str]: if region is not None and region != "us": data_file, version = download( filepath="pooled_3_year_cps_2023.h5", - huggingface_repo="policyengine-us-data", gcs_bucket="policyengine-us-data", return_version=True, ) @@ -26,7 +24,6 @@ def get_default_dataset(country: str, region: str) -> Tuple[Dataset, str]: else: data_file, version = download( filepath="cps_2023.h5", - huggingface_repo="policyengine-us-data", gcs_bucket="policyengine-us-data", return_version=True, ) diff --git a/policyengine/utils/data_download.py b/policyengine/utils/data_download.py index 10629156..ad87683e 100644 --- a/policyengine/utils/data_download.py +++ b/policyengine/utils/data_download.py @@ -8,71 +8,23 @@ from typing import Tuple -class DataFile(BaseModel): - filepath: str - huggingface_org: str - huggingface_repo: str | None = None - gcs_bucket: str | None = None - - def download( filepath: str, - huggingface_repo: str = None, - gcs_bucket: str = None, - huggingface_org: str = "policyengine", - return_version: bool = False, + gcs_bucket: str, ) -> str | Tuple[str, str]: - data_file = DataFile( - filepath=filepath, - huggingface_org=huggingface_org, - huggingface_repo=huggingface_repo, - gcs_bucket=gcs_bucket, + logging.info("Using Google Cloud Storage for download.") + download_file_from_gcs( + bucket_name=gcs_bucket, + file_name=filepath, + destination_path=filepath, ) - - logging.info = print - # NOTE: tests will break on build if you don't default to huggingface. - if data_file.huggingface_repo is not None: - logging.info("Using Hugging Face for download.") - try: - data = download_from_hf( - repo=data_file.huggingface_org - + "/" - + data_file.huggingface_repo, - repo_filename=data_file.filepath, - ) - if return_version: - version_file = download_from_hf( - repo=data_file.huggingface_org - + "/" - + data_file.huggingface_repo, - repo_filename="version.json", - return_version=True, - ) - with open(version_file, "r") as f: - version = json.load(f).get("version") - return data, version - return data - except: - logging.info("Failed to download from Hugging Face.") - - if data_file.gcs_bucket is not None: - logging.info("Using Google Cloud Storage for download.") + if return_version: download_file_from_gcs( - bucket_name=data_file.gcs_bucket, - file_name=filepath, - destination_path=filepath, + bucket_name=gcs_bucket, + file_name="version.json", + destination_path="version.json", ) - if return_version: - download_file_from_gcs( - bucket_name=data_file.gcs_bucket, - file_name="version.json", - destination_path="version.json", - ) - with open("version.json", "r") as f: - version = json.load(f).get("version") - return filepath, version - return filepath - - raise ValueError( - "No valid download method specified. Please provide either a Hugging Face repo or a Google Cloud Storage bucket." - ) + with open("version.json", "r") as f: + version = json.load(f).get("version") + return filepath, version + return filepath diff --git a/policyengine/utils/google_cloud_bucket.py b/policyengine/utils/google_cloud_bucket.py index f080c21b..c017c693 100644 --- a/policyengine/utils/google_cloud_bucket.py +++ b/policyengine/utils/google_cloud_bucket.py @@ -1,6 +1,8 @@ from .data.caching_google_storage_client import CachingGoogleStorageClient import asyncio from pathlib import Path +from google.cloud.storage import Blob +from typing import Iterable _caching_client: CachingGoogleStorageClient | None = None @@ -19,7 +21,7 @@ def _clear_client(): def download_file_from_gcs( - bucket_name: str, file_name: str, destination_path: str + bucket_name: str, file_name: str, destination_path: str, version: str = None ) -> None: """ Download a file from Google Cloud Storage to a local path. @@ -32,4 +34,18 @@ def download_file_from_gcs( Returns: None """ - _get_client().download(bucket_name, file_name, Path(destination_path)) + client = _get_client() + gcs_client = client.client.client + blob = gcs_client.bucket(bucket_name).blob(file_name) + if not blob.exists(): + raise FileNotFoundError(f"File {file_name} not found in bucket {bucket_name}") + + if version is not None: + # List blob versions + versions: Iterable[Blob] = gcs_client.list_blobs(bucket_name, prefix=file_name, versions=True) + for version in versions: + if version.metadata.get("version") == version: + file_name = version.name + break + + result = client.download(bucket_name, file_name, Path(destination_path)) From ee076ab7a05e47da69d59dbc38c191c29de69919 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 14:59:45 +0100 Subject: [PATCH 10/36] Add changes --- policyengine/constants.py | 25 +++++----- policyengine/simulation.py | 47 ++++++++----------- .../data/caching_google_storage_client.py | 46 +++++++++++++----- .../data/simplified_google_storage_client.py | 39 +++++++++++++-- policyengine/utils/data_download.py | 11 +---- policyengine/utils/google_cloud_bucket.py | 24 ++++------ 6 files changed, 108 insertions(+), 84 deletions(-) diff --git a/policyengine/constants.py b/policyengine/constants.py index 7fbc5e72..1ec040a5 100644 --- a/policyengine/constants.py +++ b/policyengine/constants.py @@ -5,34 +5,33 @@ from typing import Tuple -def get_default_dataset(country: str, region: str) -> Tuple[Dataset, str]: +def get_default_dataset( + country: str, region: str, version: str | None = None +) -> Dataset: if country == "uk": - data_file, version = download( + data_file = download( filepath="enhanced_frs_2022_23.h5", gcs_bucket="policyengine-uk-data-private", - return_version=True, + version=version, ) time_period = None elif country == "us": if region is not None and region != "us": - data_file, version = download( + data_file = download( filepath="pooled_3_year_cps_2023.h5", gcs_bucket="policyengine-us-data", - return_version=True, + version=version, ) time_period = 2023 else: - data_file, version = download( + data_file = download( filepath="cps_2023.h5", gcs_bucket="policyengine-us-data", - return_version=True, + version=version, ) time_period = 2023 - return ( - Dataset.from_file( - file_path=data_file, - time_period=time_period, - ), - version, + return Dataset.from_file( + file_path=data_file, + time_period=time_period, ) diff --git a/policyengine/simulation.py b/policyengine/simulation.py index f1811503..edf22b20 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -65,11 +65,7 @@ class SimulationOptions(BaseModel): ) package_versions: Dict[str, str] | None = Field( None, - description="The versions of the packages used in the simulation. If not provided, the current package versions will be used. If provided, this package will throw an error if the package versions do not match.", - ) - data_versions: Dict[str, str] | None = Field( - None, - description="The versions of the data used in the simulation. If not provided, the current data versions will be used. If provided, this package will throw an error if the data versions do not match.", + description="The versions of the packages used in the simulation. If not provided, the current package versions will be used. If provided, this package will throw an error if the package versions do not match. Use this as an extra safety check.", ) @@ -82,7 +78,9 @@ class Simulation: """The baseline tax-benefit simulation.""" reform_simulation: CountrySimulation | None = None """The reform tax-benefit simulation.""" - data_versions: Dict[str, str] | None = None + data_version: str | None = None + """The version of the data used in the simulation.""" + model_version: str | None = None def __init__(self, **options: SimulationOptions): self.options = SimulationOptions(**options) @@ -123,35 +121,29 @@ def _add_output_functions(self): ) def _set_data(self): - self.data_versions = {} if self.options.data is None: - self.options.data, version = get_default_dataset( + self.options.data = get_default_dataset( country=self.options.country, region=self.options.region, ) elif isinstance(self.options.data, str): filename = self.options.data - if "://" in self.options.data: - bucket = None - hf_repo = None - hf_org = None - if "gs://" in self.options.data: - bucket, filename = self.options.data.split("://")[ - -1 - ].split("/") - hf_org = "policyengine" - elif "hf://" in self.options.data: - hf_org, hf_repo, filename = self.options.data.split("://")[ - -1 - ].split("/", 2) - - file_path, version = download( + if "gcs://" in self.options.data: + bucket, filename = self.options.data.split("://")[-1].split( + "/" + ) + + if "@" in filename: + filename, version = filename.split("@") + self.data_version = version + else: + version = None + + file_path = download( filepath=filename, - huggingface_org=hf_org, - huggingface_repo=hf_repo, gcs_bucket=bucket, - return_version=True, + version=version, ) filename = str(Path(file_path)) else: @@ -165,8 +157,6 @@ def _set_data(self): filename, time_period=time_period ) - self.data_versions[self.options.data.file_path.name] = version - def _initialise_simulations(self): self.baseline_simulation = self._initialise_simulation( scope=self.options.scope, @@ -353,6 +343,7 @@ def check_package_versions(self) -> None: for package, version in self.options.package_versions.items(): try: installed_version = metadata.version(package) + self.model_version = installed_version except metadata.PackageNotFoundError: raise ValueError(f"Package {package} not found.") if installed_version != version: diff --git a/policyengine/utils/data/caching_google_storage_client.py b/policyengine/utils/data/caching_google_storage_client.py index f00d8c08..06190d45 100644 --- a/policyengine/utils/data/caching_google_storage_client.py +++ b/policyengine/utils/data/caching_google_storage_client.py @@ -18,17 +18,36 @@ def __init__(self): self.client = SimplifiedGoogleStorageClient() self.cache = diskcache.Cache() - def _data_key(self, bucket: str, key: str) -> str: - return f"{bucket}.{key}.data" + def _data_key(self, bucket: str, key: str, version: str | None) -> str: + return f"{bucket}.{key}.{version}.data" + + def _get_latest_version(self, bucket: str, key: str) -> str | None: + """ + Get the latest version of a blob in the specified bucket and key. + If no version is specified, return None. + """ + return ( + self.client.client.get_bucket(bucket) + .get_blob(key) + .metadata.get("version") + ) # To absolutely 100% avoid any possible issue with file corruption or thread contention # always replace the current target file with whatever we have cached as an atomic write. - def download(self, bucket: str, key: str, target: Path): + def download( + self, bucket: str, key: str, target: Path, version: str | None = None + ): """ Atomically write the latest version of the cloud storage blob to the target path. """ - self.sync(bucket, key) - data = self.cache.get(self._data_key(bucket, key)) + if version is None: + # If no version is specified, get the latest version from the cache + version = self._get_latest_version(bucket, key) + logging.warning( + f"No version specified for {bucket}, {key}. Using latest version: {version}" + ) + self.sync(bucket, key, version) + data = self.cache.get(self._data_key(bucket, key, version)) if type(data) is bytes: logger.info( f"Copying downloaded data for {bucket}, {key} to {target}" @@ -39,28 +58,29 @@ def download(self, bucket: str, key: str, target: Path): # If the crc has changed from what we downloaded last time download it again. # then update the CRC to whatever we actually downloaded. - def sync(self, bucket: str, key: str) -> None: + def sync(self, bucket: str, key: str, version: str | None = None) -> None: """ Cache the resource if the CRC has changed. """ - logger.info(f"Syncing {bucket}, {key} to cache") - datakey = f"{bucket}.{key}.data" - crckey = f"{bucket}.{key}.crc" + datakey = f"{bucket}.{key}.{version}.data" + crckey = f"{bucket}.{key}.{version}.crc" - crc = self.client.crc32c(bucket, key) + crc = self.client.crc32c(bucket, key, version=version) if crc is None: raise Exception(f"Unable to find {key} in bucket {bucket}") prev_crc = self.cache.get(crckey, default=None) logger.debug(f"Previous crc for {bucket}, {key} was {prev_crc}") if prev_crc == crc: - logger.info( + logger.debug( f"Cache exists and crc is unchanged for {bucket}, {key}." ) return - [content, downloaded_crc] = self.client.download(bucket, key) - logger.info( + [content, downloaded_crc] = self.client.download( + bucket, key, version=version + ) + logger.debug( f"Downloaded new version of {bucket}, {key} with crc {downloaded_crc}" ) diff --git a/policyengine/utils/data/simplified_google_storage_client.py b/policyengine/utils/data/simplified_google_storage_client.py index b7c2e895..36d08353 100644 --- a/policyengine/utils/data/simplified_google_storage_client.py +++ b/policyengine/utils/data/simplified_google_storage_client.py @@ -1,7 +1,8 @@ import asyncio from policyengine_core.data.dataset import atomic_write import logging -from google.cloud.storage import Client +from google.cloud.storage import Client, Blob +from typing import Iterable logger = logging.getLogger(__name__) @@ -17,22 +18,50 @@ class SimplifiedGoogleStorageClient: def __init__(self): self.client = Client() - def crc32c(self, bucket: str, key: str) -> str | None: + def get_versioned_blob( + self, bucket: str, key: str, version: str | None = None + ) -> Blob: + """ + Get a versioned blob from the specified bucket and key. + If version is None, returns the latest version of the blob. + """ + bucket = self.client.bucket(bucket) + if version is None: + return bucket.blob(key) + else: + versions: Iterable[Blob] = bucket.list_blobs( + prefix=key, versions=True + ) + for v in versions: + if v.metadata.get("version") == version: + return v + raise ValueError( + f"Could not find version {version} of blob {key} in bucket {bucket.name}" + ) + + def crc32c( + self, bucket: str, key: str, version: str | None = None + ) -> str | None: """ get the current CRC of the specified blob. None if it doesn't exist. """ logger.debug(f"Getting crc for {bucket}, {key}") - blob = self.client.bucket(bucket).blob(key) + bucket = self.client.bucket(bucket) + blob = self.get_versioned_blob(bucket.name, key, version) + blob.reload() logger.debug(f"Crc is {blob.crc32c}") return blob.crc32c - def download(self, bucket: str, key: str) -> tuple[bytes, str]: + def download( + self, bucket: str, key: str, version: str | None = None + ) -> tuple[bytes, str]: """ get the blob content and associated CRC from google storage. """ logger.debug(f"Downloading {bucket}, {key}") - blob = self.client.bucket(bucket).blob(key) + blob = self.get_versioned_blob(bucket, key, version) + print(blob, blob.exists()) result = blob.download_as_bytes() # According to documentation blob.crc32c is updated as a side effect of diff --git a/policyengine/utils/data_download.py b/policyengine/utils/data_download.py index ad87683e..04342c21 100644 --- a/policyengine/utils/data_download.py +++ b/policyengine/utils/data_download.py @@ -11,20 +11,13 @@ def download( filepath: str, gcs_bucket: str, + version: str | None = None, ) -> str | Tuple[str, str]: logging.info("Using Google Cloud Storage for download.") download_file_from_gcs( bucket_name=gcs_bucket, file_name=filepath, destination_path=filepath, + version=version, ) - if return_version: - download_file_from_gcs( - bucket_name=gcs_bucket, - file_name="version.json", - destination_path="version.json", - ) - with open("version.json", "r") as f: - version = json.load(f).get("version") - return filepath, version return filepath diff --git a/policyengine/utils/google_cloud_bucket.py b/policyengine/utils/google_cloud_bucket.py index c017c693..3516b231 100644 --- a/policyengine/utils/google_cloud_bucket.py +++ b/policyengine/utils/google_cloud_bucket.py @@ -21,7 +21,10 @@ def _clear_client(): def download_file_from_gcs( - bucket_name: str, file_name: str, destination_path: str, version: str = None + bucket_name: str, + file_name: str, + destination_path: str, + version: str = None, ) -> None: """ Download a file from Google Cloud Storage to a local path. @@ -34,18 +37,7 @@ def download_file_from_gcs( Returns: None """ - client = _get_client() - gcs_client = client.client.client - blob = gcs_client.bucket(bucket_name).blob(file_name) - if not blob.exists(): - raise FileNotFoundError(f"File {file_name} not found in bucket {bucket_name}") - - if version is not None: - # List blob versions - versions: Iterable[Blob] = gcs_client.list_blobs(bucket_name, prefix=file_name, versions=True) - for version in versions: - if version.metadata.get("version") == version: - file_name = version.name - break - - result = client.download(bucket_name, file_name, Path(destination_path)) + + return _get_client().download( + bucket_name, file_name, Path(destination_path), version=version + ) From 827776c77750d4a5678952e045870f6ca6a65951 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 15:00:46 +0100 Subject: [PATCH 11/36] Add pip install prompt --- policyengine/simulation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policyengine/simulation.py b/policyengine/simulation.py index edf22b20..8ae81dca 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -345,7 +345,9 @@ def check_package_versions(self) -> None: installed_version = metadata.version(package) self.model_version = installed_version except metadata.PackageNotFoundError: - raise ValueError(f"Package {package} not found.") + raise ValueError( + f"Package {package} not found. Try running `pip install {package}`." + ) if installed_version != version: raise ValueError( f"Package {package} version {installed_version} does not match expected version {version}." From b0928f863e6d00155270a4791b4c0a98798677b1 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 17:42:30 +0100 Subject: [PATCH 12/36] Minor improvements --- policyengine/constants.py | 28 ++++----------- policyengine/simulation.py | 35 ++++++++++--------- .../data/caching_google_storage_client.py | 5 +-- .../data/simplified_google_storage_client.py | 3 +- 4 files changed, 28 insertions(+), 43 deletions(-) diff --git a/policyengine/constants.py b/policyengine/constants.py index 1ec040a5..89bd88b7 100644 --- a/policyengine/constants.py +++ b/policyengine/constants.py @@ -7,31 +7,15 @@ def get_default_dataset( country: str, region: str, version: str | None = None -) -> Dataset: +) -> str: if country == "uk": - data_file = download( - filepath="enhanced_frs_2022_23.h5", - gcs_bucket="policyengine-uk-data-private", - version=version, - ) - time_period = None + return "gcs://policyengine-uk-data-private/enhanced_frs_2022_23.h5" elif country == "us": if region is not None and region != "us": - data_file = download( - filepath="pooled_3_year_cps_2023.h5", - gcs_bucket="policyengine-us-data", - version=version, - ) - time_period = 2023 + return "gcs://policyengine-us-data/pooled_3_year_cps_2023.h5" else: - data_file = download( - filepath="cps_2023.h5", - gcs_bucket="policyengine-us-data", - version=version, - ) - time_period = 2023 + return "gcs://policyengine-us-data/cps_2023.h5" - return Dataset.from_file( - file_path=data_file, - time_period=time_period, + raise ValueError( + f"Unable to select a default dataset for country {country} and region {region}." ) diff --git a/policyengine/simulation.py b/policyengine/simulation.py index 8ae81dca..26872759 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -63,9 +63,9 @@ class SimulationOptions(BaseModel): False, description="Whether to include tax-benefit cliffs in the simulation analyses. If True, cliffs will be included.", ) - package_versions: Dict[str, str] | None = Field( + model_version: str | None = Field( None, - description="The versions of the packages used in the simulation. If not provided, the current package versions will be used. If provided, this package will throw an error if the package versions do not match. Use this as an extra safety check.", + description="The version of the country model used in the simulation. If not provided, the current package version will be used. If provided, this package will throw an error if the package version does not match. Use this as an extra safety check.", ) @@ -127,7 +127,7 @@ def _set_data(self): region=self.options.region, ) - elif isinstance(self.options.data, str): + if isinstance(self.options.data, str): filename = self.options.data if "gcs://" in self.options.data: bucket, filename = self.options.data.split("://")[-1].split( @@ -335,23 +335,24 @@ def _apply_region_to_simulation( return simulation - def check_package_versions(self) -> None: + def check_package_version(self) -> None: """ Check the package versions of the simulation against the current package versions. """ - if self.options.package_versions is not None: - for package, version in self.options.package_versions.items(): - try: - installed_version = metadata.version(package) - self.model_version = installed_version - except metadata.PackageNotFoundError: - raise ValueError( - f"Package {package} not found. Try running `pip install {package}`." - ) - if installed_version != version: - raise ValueError( - f"Package {package} version {installed_version} does not match expected version {version}." - ) + if self.options.model_version is not None: + target_version = self.options.model_version + package = f"policyengine-{self.options.country}" + try: + installed_version = metadata.version(package) + self.model_version = installed_version + except metadata.PackageNotFoundError: + raise ValueError( + f"Package {package} not found. Try running `pip install {package}`." + ) + if installed_version != target_version: + raise ValueError( + f"Package {package} version {installed_version} does not match expected version {target_version}. Try running `pip install {package}=={target_version}`." + ) def check_data_versions(self) -> None: """ diff --git a/policyengine/utils/data/caching_google_storage_client.py b/policyengine/utils/data/caching_google_storage_client.py index 06190d45..430fede2 100644 --- a/policyengine/utils/data/caching_google_storage_client.py +++ b/policyengine/utils/data/caching_google_storage_client.py @@ -62,6 +62,7 @@ def sync(self, bucket: str, key: str, version: str | None = None) -> None: """ Cache the resource if the CRC has changed. """ + logger.info(f"Syncing {bucket}, {key}, {version} to cache") datakey = f"{bucket}.{key}.{version}.data" crckey = f"{bucket}.{key}.{version}.crc" @@ -70,9 +71,9 @@ def sync(self, bucket: str, key: str, version: str | None = None) -> None: raise Exception(f"Unable to find {key} in bucket {bucket}") prev_crc = self.cache.get(crckey, default=None) - logger.debug(f"Previous crc for {bucket}, {key} was {prev_crc}") + logger.info(f"Previous crc for {bucket}, {key} was {prev_crc}") if prev_crc == crc: - logger.debug( + logger.info( f"Cache exists and crc is unchanged for {bucket}, {key}." ) return diff --git a/policyengine/utils/data/simplified_google_storage_client.py b/policyengine/utils/data/simplified_google_storage_client.py index 36d08353..7c0d721e 100644 --- a/policyengine/utils/data/simplified_google_storage_client.py +++ b/policyengine/utils/data/simplified_google_storage_client.py @@ -59,9 +59,8 @@ def download( """ get the blob content and associated CRC from google storage. """ - logger.debug(f"Downloading {bucket}, {key}") + logger.info(f"Downloading {bucket}, {key}") blob = self.get_versioned_blob(bucket, key, version) - print(blob, blob.exists()) result = blob.download_as_bytes() # According to documentation blob.crc32c is updated as a side effect of From 78cdfa9d1393e6aca1c22660a0aeb070b27e2ade Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 18:27:43 +0100 Subject: [PATCH 13/36] Add handling for no metadata version --- .../utils/data/caching_google_storage_client.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/policyengine/utils/data/caching_google_storage_client.py b/policyengine/utils/data/caching_google_storage_client.py index 430fede2..5b2bbc17 100644 --- a/policyengine/utils/data/caching_google_storage_client.py +++ b/policyengine/utils/data/caching_google_storage_client.py @@ -26,11 +26,14 @@ def _get_latest_version(self, bucket: str, key: str) -> str | None: Get the latest version of a blob in the specified bucket and key. If no version is specified, return None. """ - return ( - self.client.client.get_bucket(bucket) - .get_blob(key) - .metadata.get("version") - ) + blob = self.client.client.get_bucket(bucket).get_blob(key) + if blob.metadata is None: + logging.warning( + "No metadata found for blob, so it has no version attached." + ) + return None + else: + return blob.metadata.get("version") # To absolutely 100% avoid any possible issue with file corruption or thread contention # always replace the current target file with whatever we have cached as an atomic write. From a0de606cc076316dbf11ecae61218b359622922f Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 18:59:30 +0100 Subject: [PATCH 14/36] Fix some tests --- .github/workflows/any_changes.yaml | 2 - .github/workflows/code_changes.yaml | 4 +- .github/workflows/publish_documentation.yaml | 2 - .github/workflows/publish_package.yaml | 2 - .gitignore | 2 + .../calculate_economy_comparison.py | 2 - policyengine/simulation.py | 15 ++---- .../data/caching_google_storage_client.py | 16 +----- .../data/simplified_google_storage_client.py | 14 ++++++ policyengine/utils/data_download.py | 1 - policyengine/utils/huggingface.py | 49 ------------------- tests/country/test_uk.py | 10 ++-- tests/utils/data/conftest.py | 7 +++ tests/utils/data/test_google_cloud_bucket.py | 11 +++-- 14 files changed, 40 insertions(+), 97 deletions(-) delete mode 100644 policyengine/utils/huggingface.py diff --git a/.github/workflows/any_changes.yaml b/.github/workflows/any_changes.yaml index 91d39f36..84f81348 100644 --- a/.github/workflows/any_changes.yaml +++ b/.github/workflows/any_changes.yaml @@ -26,8 +26,6 @@ jobs: - name: Test documentation builds run: make documentation - env: - HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} - name: Check documentation build run: | diff --git a/.github/workflows/code_changes.yaml b/.github/workflows/code_changes.yaml index b45fed1e..3bb144cd 100644 --- a/.github/workflows/code_changes.yaml +++ b/.github/workflows/code_changes.yaml @@ -43,6 +43,4 @@ jobs: run: uv pip install .[dev] --system - name: Run tests - run: make test - env: - HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} \ No newline at end of file + run: make test \ No newline at end of file diff --git a/.github/workflows/publish_documentation.yaml b/.github/workflows/publish_documentation.yaml index a739ac38..10143cb0 100644 --- a/.github/workflows/publish_documentation.yaml +++ b/.github/workflows/publish_documentation.yaml @@ -29,8 +29,6 @@ jobs: - name: Build documentation run: make documentation - env: - HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} - name: Deploy documentation uses: JamesIves/github-pages-deploy-action@releases/v3 diff --git a/.github/workflows/publish_package.yaml b/.github/workflows/publish_package.yaml index 9a7fcc1e..167f0685 100644 --- a/.github/workflows/publish_package.yaml +++ b/.github/workflows/publish_package.yaml @@ -36,8 +36,6 @@ jobs: - name: Test documentation builds run: make documentation - env: - HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }} - name: Deploy documentation uses: JamesIves/github-pages-deploy-action@releases/v3 diff --git a/.gitignore b/.gitignore index 42030f64..57a4e3e2 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,5 @@ cython_debug/ *.ipynb !docs/**/*.ipynb + +**/*.h5 diff --git a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py index 636717c5..bd1848f0 100644 --- a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py +++ b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py @@ -711,7 +711,6 @@ def uk_constituency_breakdown( reform_hnet = reform.household_net_income constituency_weights_path = download( - huggingface_repo="policyengine-uk-data", gcs_bucket="policyengine-uk-data-private", filepath="parliamentary_constituency_weights.h5", ) @@ -721,7 +720,6 @@ def uk_constituency_breakdown( ] # {2025: array(650, 100180) where cell i, j is the weight of household record i in constituency j} constituency_names_path = download( - huggingface_repo="policyengine-uk-data", gcs_bucket="policyengine-uk-data-private", filepath="constituencies_2024.csv", ) diff --git a/policyengine/simulation.py b/policyengine/simulation.py index 26872759..b88d6b6c 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -67,6 +67,10 @@ class SimulationOptions(BaseModel): None, description="The version of the country model used in the simulation. If not provided, the current package version will be used. If provided, this package will throw an error if the package version does not match. Use this as an extra safety check.", ) + data_version: str | None = Field( + None, + description="The version of the data used in the simulation. If not provided, the current data version will be used. If provided, this package will throw an error if the data version does not match. Use this as an extra safety check.", + ) class Simulation: @@ -133,12 +137,7 @@ def _set_data(self): bucket, filename = self.options.data.split("://")[-1].split( "/" ) - - if "@" in filename: - filename, version = filename.split("@") - self.data_version = version - else: - version = None + version = self.options.data_version file_path = download( filepath=filename, @@ -267,7 +266,6 @@ def _apply_region_to_simulation( elif "constituency/" in region: constituency = region.split("/")[1] constituency_names_file_path = download( - huggingface_repo="policyengine-uk-data", gcs_bucket="policyengine-uk-data-private", filepath="constituencies_2024.csv", ) @@ -288,7 +286,6 @@ def _apply_region_to_simulation( f"Constituency {constituency} not found. See {constituency_names_file_path} for the list of available constituencies." ) weights_file_path = download( - huggingface_repo="policyengine-uk-data", gcs_bucket="policyengine-uk-data-private", filepath="parliamentary_constituency_weights.h5", ) @@ -304,7 +301,6 @@ def _apply_region_to_simulation( elif "local_authority/" in region: la = region.split("/")[1] la_names_file_path = download( - huggingface_repo="policyengine-uk-data", gcs_bucket="policyengine-uk-data-private", filepath="local_authorities_2021.csv", ) @@ -319,7 +315,6 @@ def _apply_region_to_simulation( f"Local authority {la} not found. See {la_names_file_path} for the list of available local authorities." ) weights_file_path = download( - huggingface_repo="policyengine-uk-data", gcs_bucket="policyengine-uk-data-private", filepath="local_authority_weights.h5", ) diff --git a/policyengine/utils/data/caching_google_storage_client.py b/policyengine/utils/data/caching_google_storage_client.py index 5b2bbc17..0a7988bd 100644 --- a/policyengine/utils/data/caching_google_storage_client.py +++ b/policyengine/utils/data/caching_google_storage_client.py @@ -21,20 +21,6 @@ def __init__(self): def _data_key(self, bucket: str, key: str, version: str | None) -> str: return f"{bucket}.{key}.{version}.data" - def _get_latest_version(self, bucket: str, key: str) -> str | None: - """ - Get the latest version of a blob in the specified bucket and key. - If no version is specified, return None. - """ - blob = self.client.client.get_bucket(bucket).get_blob(key) - if blob.metadata is None: - logging.warning( - "No metadata found for blob, so it has no version attached." - ) - return None - else: - return blob.metadata.get("version") - # To absolutely 100% avoid any possible issue with file corruption or thread contention # always replace the current target file with whatever we have cached as an atomic write. def download( @@ -45,7 +31,7 @@ def download( """ if version is None: # If no version is specified, get the latest version from the cache - version = self._get_latest_version(bucket, key) + version = self.client._get_latest_version(bucket, key) logging.warning( f"No version specified for {bucket}, {key}. Using latest version: {version}" ) diff --git a/policyengine/utils/data/simplified_google_storage_client.py b/policyengine/utils/data/simplified_google_storage_client.py index 7c0d721e..618043dd 100644 --- a/policyengine/utils/data/simplified_google_storage_client.py +++ b/policyengine/utils/data/simplified_google_storage_client.py @@ -67,3 +67,17 @@ def download( # downloading the content. As a result this should now be the crc of the downloaded # content (i.e. there is not a race condition where it's getting the CRC from the cloud) return (result, blob.crc32c) + + def _get_latest_version(self, bucket: str, key: str) -> str | None: + """ + Get the latest version of a blob in the specified bucket and key. + If no version is specified, return None. + """ + blob = self.client.get_bucket(bucket).get_blob(key) + if blob.metadata is None: + logging.warning( + "No metadata found for blob, so it has no version attached." + ) + return None + else: + return blob.metadata.get("version") diff --git a/policyengine/utils/data_download.py b/policyengine/utils/data_download.py index 04342c21..edb3104a 100644 --- a/policyengine/utils/data_download.py +++ b/policyengine/utils/data_download.py @@ -1,7 +1,6 @@ from pathlib import Path import logging import os -from policyengine.utils.huggingface import download_from_hf from policyengine.utils.google_cloud_bucket import download_file_from_gcs from pydantic import BaseModel import json diff --git a/policyengine/utils/huggingface.py b/policyengine/utils/huggingface.py deleted file mode 100644 index 8277c7a4..00000000 --- a/policyengine/utils/huggingface.py +++ /dev/null @@ -1,49 +0,0 @@ -from huggingface_hub import hf_hub_download -import os -from getpass import getpass -import time - - -def download_from_hf( - repo: str, - repo_filename: str, - local_folder: str | None = None, - version: str | None = None, -): - token = os.environ.get("HUGGING_FACE_TOKEN") - if token is None: - token = getpass( - "Enter your Hugging Face token (or set HUGGING_FACE_TOKEN environment variable): " - ) - # Optionally store in env for subsequent calls in same session - os.environ["HUGGING_FACE_TOKEN"] = token - try: - result = hf_hub_download( - repo_id=repo, - repo_type="model", - filename=repo_filename, - local_dir=local_folder, - revision=version, - token=token, - ) - except: - # In the case of a 429 Too Many Requests error, retry up to 5 times, waiting 30 seconds - # between attempts - for i in range(5): - try: - result = hf_hub_download( - repo_id=repo, - repo_type="model", - filename=repo_filename, - local_dir=local_folder, - revision=version, - token=token, - ) - break - except Exception as e: - if i == 4: - raise e - print(f"Error downloading {repo_filename} from {repo}: {e}") - print("Retrying in 30 seconds...") - time.sleep(30) - return result diff --git a/tests/country/test_uk.py b/tests/country/test_uk.py index e47877e4..801dfc4a 100644 --- a/tests/country/test_uk.py +++ b/tests/country/test_uk.py @@ -33,9 +33,7 @@ def test_uk_macro_bad_package_versions_fail(): reform={ "gov.hmrc.income_tax.allowances.personal_allowance.amount": 15_000, }, - package_versions={ - "policyengine-uk": "-1.0.0", - }, + model_version="a", ) raise ValueError( "Simulation should have failed with a bad package version." @@ -44,7 +42,7 @@ def test_uk_macro_bad_package_versions_fail(): pass -def test_uk_macro_bad_data_versions_fail(): +def test_uk_macro_bad_data_version_fails(): from policyengine import Simulation try: @@ -54,9 +52,7 @@ def test_uk_macro_bad_data_versions_fail(): reform={ "gov.hmrc.income_tax.allowances.personal_allowance.amount": 15_000, }, - data_versions={ - "enhanced_frs_2022_23.h5": "-1.0.0", - }, + data_version="a", ) raise ValueError( "Simulation should have failed with a bad data version." diff --git a/tests/utils/data/conftest.py b/tests/utils/data/conftest.py index 7a692420..3b204528 100644 --- a/tests/utils/data/conftest.py +++ b/tests/utils/data/conftest.py @@ -12,6 +12,10 @@ def given_stored_data(self, data: str, crc: str): data.encode(), crc, ) + print("Setting latest version") + self.mock_simple_storage_client._get_latest_version.return_value = ( + "1.2.3" + ) def given_crc_changes_on_download( self, data: str, initial_crc: str, download_crc: str @@ -21,6 +25,9 @@ def given_crc_changes_on_download( data.encode(), download_crc, ) + self.mock_simple_storage_client._get_latest_version.return_value = ( + "1.2.3" + ) @pytest.fixture() diff --git a/tests/utils/data/test_google_cloud_bucket.py b/tests/utils/data/test_google_cloud_bucket.py index c141c0f9..e1e2abe9 100644 --- a/tests/utils/data/test_google_cloud_bucket.py +++ b/tests/utils/data/test_google_cloud_bucket.py @@ -19,10 +19,13 @@ def setUp(self): def test_download_uses_storage_client(self, client_class): client_instance = client_class.return_value download_file_from_gcs( - "TEST_BUCKET", "TEST/FILE/NAME.TXT", "TARGET/PATH" + "TEST_BUCKET", "TEST/FILE/NAME.TXT", "TARGET/PATH", version=None ) client_instance.download.assert_called_with( - "TEST_BUCKET", "TEST/FILE/NAME.TXT", Path("TARGET/PATH") + "TEST_BUCKET", + "TEST/FILE/NAME.TXT", + Path("TARGET/PATH"), + version=None, ) @patch( @@ -31,9 +34,9 @@ def test_download_uses_storage_client(self, client_class): ) def test_download_only_creates_client_once(self, client_class): download_file_from_gcs( - "TEST_BUCKET", "TEST/FILE/NAME.TXT", "TARGET/PATH" + "TEST_BUCKET", "TEST/FILE/NAME.TXT", "TARGET/PATH", version=None ) download_file_from_gcs( - "TEST_BUCKET", "TEST/FILE/NAME.TXT", "ANOTHER/PATH" + "TEST_BUCKET", "TEST/FILE/NAME.TXT", "ANOTHER/PATH", version=None ) client_class.assert_called_once() From 9faeeb2923b667ee997780c98cb4328a191e74fd Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 19:02:42 +0100 Subject: [PATCH 15/36] Add dataset constants --- policyengine/constants.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/policyengine/constants.py b/policyengine/constants.py index 89bd88b7..1fde9c46 100644 --- a/policyengine/constants.py +++ b/policyengine/constants.py @@ -4,17 +4,23 @@ from policyengine.utils.data_download import download from typing import Tuple +EFRS_2022 = "gcs://policyengine-uk-data-private/enhanced_frs_2022_23.h5" +FRS_2022 = "gcs://policyengine-uk-data-private/frs_2022_23.h5" +CPS_2023_POOLED = "gcs://policyengine-us-data/pooled_3_year_cps_2023.h5" +CPS_2023 = "gcs://policyengine-us-data/cps_2023.h5" +ECPS_2024 = "gcs://policyengine-us-data/ecps_2024.h5" + def get_default_dataset( country: str, region: str, version: str | None = None ) -> str: if country == "uk": - return "gcs://policyengine-uk-data-private/enhanced_frs_2022_23.h5" + return EFRS_2022 elif country == "us": if region is not None and region != "us": - return "gcs://policyengine-us-data/pooled_3_year_cps_2023.h5" + return CPS_2023_POOLED else: - return "gcs://policyengine-us-data/cps_2023.h5" + return CPS_2023 raise ValueError( f"Unable to select a default dataset for country {country} and region {region}." From c3fdbde6f0cda5d3b4fecf9ff5c2a371a5894a92 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 19:08:44 +0100 Subject: [PATCH 16/36] Fix str | None --- policyengine/constants.py | 4 ++-- policyengine/simulation.py | 18 +++++++++--------- .../data/caching_google_storage_client.py | 7 ++++--- .../data/simplified_google_storage_client.py | 12 ++++++------ policyengine/utils/data_download.py | 4 ++-- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/policyengine/constants.py b/policyengine/constants.py index 1fde9c46..de9b6799 100644 --- a/policyengine/constants.py +++ b/policyengine/constants.py @@ -2,7 +2,7 @@ from policyengine_core.data import Dataset from policyengine.utils.data_download import download -from typing import Tuple +from typing import Tuple, Optional EFRS_2022 = "gcs://policyengine-uk-data-private/enhanced_frs_2022_23.h5" FRS_2022 = "gcs://policyengine-uk-data-private/frs_2022_23.h5" @@ -12,7 +12,7 @@ def get_default_dataset( - country: str, region: str, version: str | None = None + country: str, region: str, version: Optional[str] = None ) -> str: if country == "uk": return EFRS_2022 diff --git a/policyengine/simulation.py b/policyengine/simulation.py index b88d6b6c..2eba8569 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -22,7 +22,7 @@ import h5py from pathlib import Path import pandas as pd -from typing import Type +from typing import Type, Optional from functools import wraps, partial from typing import Dict, Any, Callable import importlib @@ -35,8 +35,8 @@ ) # Needs stricter typing. Any==policyengine_core.data.Dataset, but pydantic refuses for some reason. TimePeriodType = int ReformType = ParametricReform | Type[StructuralReform] | None -RegionType = str | None -SubsampleType = int | None +RegionType = Optional[str] +SubsampleType = Optional[int] class SimulationOptions(BaseModel): @@ -55,19 +55,19 @@ class SimulationOptions(BaseModel): None, description="How many, if a subsample, households to randomly simulate.", ) - title: str | None = Field( + title: Optional[str] = Field( "[Analysis title]", description="The title of the analysis (for charts). If not provided, a default title will be generated.", ) - include_cliffs: bool | None = Field( + include_cliffs: Optional[bool] = Field( False, description="Whether to include tax-benefit cliffs in the simulation analyses. If True, cliffs will be included.", ) - model_version: str | None = Field( + model_version: Optional[str] = Field( None, description="The version of the country model used in the simulation. If not provided, the current package version will be used. If provided, this package will throw an error if the package version does not match. Use this as an extra safety check.", ) - data_version: str | None = Field( + data_version: Optional[str] = Field( None, description="The version of the data used in the simulation. If not provided, the current data version will be used. If provided, this package will throw an error if the data version does not match. Use this as an extra safety check.", ) @@ -82,9 +82,9 @@ class Simulation: """The baseline tax-benefit simulation.""" reform_simulation: CountrySimulation | None = None """The reform tax-benefit simulation.""" - data_version: str | None = None + data_version: Optional[str] = None """The version of the data used in the simulation.""" - model_version: str | None = None + model_version: Optional[str] = None def __init__(self, **options: SimulationOptions): self.options = SimulationOptions(**options) diff --git a/policyengine/utils/data/caching_google_storage_client.py b/policyengine/utils/data/caching_google_storage_client.py index 0a7988bd..5faece95 100644 --- a/policyengine/utils/data/caching_google_storage_client.py +++ b/policyengine/utils/data/caching_google_storage_client.py @@ -4,6 +4,7 @@ from policyengine_core.data.dataset import atomic_write import logging from .simplified_google_storage_client import SimplifiedGoogleStorageClient +from typing import Optional logger = logging.getLogger(__name__) @@ -18,13 +19,13 @@ def __init__(self): self.client = SimplifiedGoogleStorageClient() self.cache = diskcache.Cache() - def _data_key(self, bucket: str, key: str, version: str | None) -> str: + def _data_key(self, bucket: str, key: str, version: Optional[str] = None) -> str: return f"{bucket}.{key}.{version}.data" # To absolutely 100% avoid any possible issue with file corruption or thread contention # always replace the current target file with whatever we have cached as an atomic write. def download( - self, bucket: str, key: str, target: Path, version: str | None = None + self, bucket: str, key: str, target: Path, version: Optional[str] = None ): """ Atomically write the latest version of the cloud storage blob to the target path. @@ -47,7 +48,7 @@ def download( # If the crc has changed from what we downloaded last time download it again. # then update the CRC to whatever we actually downloaded. - def sync(self, bucket: str, key: str, version: str | None = None) -> None: + def sync(self, bucket: str, key: str, Optional[str] = None) -> None: """ Cache the resource if the CRC has changed. """ diff --git a/policyengine/utils/data/simplified_google_storage_client.py b/policyengine/utils/data/simplified_google_storage_client.py index 618043dd..cf23bb81 100644 --- a/policyengine/utils/data/simplified_google_storage_client.py +++ b/policyengine/utils/data/simplified_google_storage_client.py @@ -2,7 +2,7 @@ from policyengine_core.data.dataset import atomic_write import logging from google.cloud.storage import Client, Blob -from typing import Iterable +from typing import Iterable, Optional logger = logging.getLogger(__name__) @@ -19,7 +19,7 @@ def __init__(self): self.client = Client() def get_versioned_blob( - self, bucket: str, key: str, version: str | None = None + self, bucket: str, key: str, version: Optional[str] = None ) -> Blob: """ Get a versioned blob from the specified bucket and key. @@ -40,8 +40,8 @@ def get_versioned_blob( ) def crc32c( - self, bucket: str, key: str, version: str | None = None - ) -> str | None: + self, bucket: str, key: str, version: Optional[str] = None + ) -> Optional[str]: """ get the current CRC of the specified blob. None if it doesn't exist. """ @@ -54,7 +54,7 @@ def crc32c( return blob.crc32c def download( - self, bucket: str, key: str, version: str | None = None + self, bucket: str, key: str, version: Optional[str] = None ) -> tuple[bytes, str]: """ get the blob content and associated CRC from google storage. @@ -68,7 +68,7 @@ def download( # content (i.e. there is not a race condition where it's getting the CRC from the cloud) return (result, blob.crc32c) - def _get_latest_version(self, bucket: str, key: str) -> str | None: + def _get_latest_version(self, bucket: str, key: str) -> Optional[str]: """ Get the latest version of a blob in the specified bucket and key. If no version is specified, return None. diff --git a/policyengine/utils/data_download.py b/policyengine/utils/data_download.py index edb3104a..307d2794 100644 --- a/policyengine/utils/data_download.py +++ b/policyengine/utils/data_download.py @@ -4,13 +4,13 @@ from policyengine.utils.google_cloud_bucket import download_file_from_gcs from pydantic import BaseModel import json -from typing import Tuple +from typing import Tuple, Optional def download( filepath: str, gcs_bucket: str, - version: str | None = None, + version: Optional[str] = None, ) -> str | Tuple[str, str]: logging.info("Using Google Cloud Storage for download.") download_file_from_gcs( From 8bc2c220b69f531449ebbe44178de99a7a4602ea Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 19:10:59 +0100 Subject: [PATCH 17/36] Address comment --- policyengine/simulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine/simulation.py b/policyengine/simulation.py index 2eba8569..9dd7e37e 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -133,7 +133,7 @@ def _set_data(self): if isinstance(self.options.data, str): filename = self.options.data - if "gcs://" in self.options.data: + if self.options.data[:6] == "gcs://": bucket, filename = self.options.data.split("://")[-1].split( "/" ) From 88ef3e62c735610d16692f45c6a4af00fc8db882 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 19:11:38 +0100 Subject: [PATCH 18/36] Call check package version --- policyengine/simulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine/simulation.py b/policyengine/simulation.py index 9dd7e37e..e459798e 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -88,7 +88,7 @@ class Simulation: def __init__(self, **options: SimulationOptions): self.options = SimulationOptions(**options) - + self.check_package_version() self._set_data() self._initialise_simulations() self._add_output_functions() From 040d393e02a1def2866f56466f27c68d78f9322b Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 19:12:32 +0100 Subject: [PATCH 19/36] Model, not package --- policyengine/simulation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine/simulation.py b/policyengine/simulation.py index e459798e..3e6c11f0 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -88,7 +88,7 @@ class Simulation: def __init__(self, **options: SimulationOptions): self.options = SimulationOptions(**options) - self.check_package_version() + self.check_model_version() self._set_data() self._initialise_simulations() self._add_output_functions() @@ -330,7 +330,7 @@ def _apply_region_to_simulation( return simulation - def check_package_version(self) -> None: + def check_model_version(self) -> None: """ Check the package versions of the simulation against the current package versions. """ From 2aff263d818a04752230ef74054280f422b1cb23 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 19:14:36 +0100 Subject: [PATCH 20/36] Fix data version check --- policyengine/simulation.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/policyengine/simulation.py b/policyengine/simulation.py index 3e6c11f0..e943117d 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -91,6 +91,7 @@ def __init__(self, **options: SimulationOptions): self.check_model_version() self._set_data() self._initialise_simulations() + self.check_data_version() self._add_output_functions() def _add_output_functions(self): @@ -349,17 +350,12 @@ def check_model_version(self) -> None: f"Package {package} version {installed_version} does not match expected version {target_version}. Try running `pip install {package}=={target_version}`." ) - def check_data_versions(self) -> None: + def check_data_version(self) -> None: """ Check the data versions of the simulation against the current data versions. """ - if self.options.data_versions is not None: - for file, version in self.options.data_versions.items(): - if file not in self.data_versions: - raise ValueError( - f"Data file {file} not found in simulation." - ) - if self.data_versions[file] != version: - raise ValueError( - f"Data file {file} version {self.data_versions[file]} does not match expected version {version}." + if self.options.data_version is not None: + if self.data_version != self.options.data_version: + raise ValueError( + f"Data version {self.data_version} does not match expected version {self.options.data_version}." ) From 6a943591e33767a05e7e337fa5b9b02bb77ab52e Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 19:15:10 +0100 Subject: [PATCH 21/36] Revert log level to debug --- policyengine/utils/data/caching_google_storage_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine/utils/data/caching_google_storage_client.py b/policyengine/utils/data/caching_google_storage_client.py index 5faece95..e006adfa 100644 --- a/policyengine/utils/data/caching_google_storage_client.py +++ b/policyengine/utils/data/caching_google_storage_client.py @@ -61,7 +61,7 @@ def sync(self, bucket: str, key: str, Optional[str] = None) -> None: raise Exception(f"Unable to find {key} in bucket {bucket}") prev_crc = self.cache.get(crckey, default=None) - logger.info(f"Previous crc for {bucket}, {key} was {prev_crc}") + logger.debug(f"Previous crc for {bucket}, {key} was {prev_crc}") if prev_crc == crc: logger.info( f"Cache exists and crc is unchanged for {bucket}, {key}." From cce966d5afbdbfb9f4e883a8369b61d2c03da011 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 19:16:53 +0100 Subject: [PATCH 22/36] Fix syntax error --- policyengine/simulation.py | 2 +- .../utils/data/caching_google_storage_client.py | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/policyengine/simulation.py b/policyengine/simulation.py index e943117d..93fb5a94 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -358,4 +358,4 @@ def check_data_version(self) -> None: if self.data_version != self.options.data_version: raise ValueError( f"Data version {self.data_version} does not match expected version {self.options.data_version}." - ) + ) diff --git a/policyengine/utils/data/caching_google_storage_client.py b/policyengine/utils/data/caching_google_storage_client.py index e006adfa..10de203a 100644 --- a/policyengine/utils/data/caching_google_storage_client.py +++ b/policyengine/utils/data/caching_google_storage_client.py @@ -19,13 +19,19 @@ def __init__(self): self.client = SimplifiedGoogleStorageClient() self.cache = diskcache.Cache() - def _data_key(self, bucket: str, key: str, version: Optional[str] = None) -> str: + def _data_key( + self, bucket: str, key: str, version: Optional[str] = None + ) -> str: return f"{bucket}.{key}.{version}.data" # To absolutely 100% avoid any possible issue with file corruption or thread contention # always replace the current target file with whatever we have cached as an atomic write. def download( - self, bucket: str, key: str, target: Path, version: Optional[str] = None + self, + bucket: str, + key: str, + target: Path, + version: Optional[str] = None, ): """ Atomically write the latest version of the cloud storage blob to the target path. @@ -48,7 +54,9 @@ def download( # If the crc has changed from what we downloaded last time download it again. # then update the CRC to whatever we actually downloaded. - def sync(self, bucket: str, key: str, Optional[str] = None) -> None: + def sync( + self, bucket: str, key: str, version: Optional[str] = None + ) -> None: """ Cache the resource if the CRC has changed. """ From 643f5a5f38002c37706fe0ce369d50bf60416c48 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 19:27:41 +0100 Subject: [PATCH 23/36] Fix bugs in tests --- .../utils/data/simplified_google_storage_client.py | 6 +++--- tests/utils/data/test_simplified_google_storage_client.py | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/policyengine/utils/data/simplified_google_storage_client.py b/policyengine/utils/data/simplified_google_storage_client.py index cf23bb81..37e1b46d 100644 --- a/policyengine/utils/data/simplified_google_storage_client.py +++ b/policyengine/utils/data/simplified_google_storage_client.py @@ -40,13 +40,13 @@ def get_versioned_blob( ) def crc32c( - self, bucket: str, key: str, version: Optional[str] = None + self, bucket_name: str, key: str, version: Optional[str] = None ) -> Optional[str]: """ get the current CRC of the specified blob. None if it doesn't exist. """ - logger.debug(f"Getting crc for {bucket}, {key}") - bucket = self.client.bucket(bucket) + logger.debug(f"Getting crc for {bucket_name}, {key}") + bucket = self.client.bucket(bucket_name) blob = self.get_versioned_blob(bucket.name, key, version) blob.reload() diff --git a/tests/utils/data/test_simplified_google_storage_client.py b/tests/utils/data/test_simplified_google_storage_client.py index fc692f02..b9df13aa 100644 --- a/tests/utils/data/test_simplified_google_storage_client.py +++ b/tests/utils/data/test_simplified_google_storage_client.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import patch, call import pytest from policyengine.utils.data import SimplifiedGoogleStorageClient @@ -17,7 +17,11 @@ def test_crc32c__gets_crc(self, mock_client_class): client = SimplifiedGoogleStorageClient() assert client.crc32c("bucket_name", "content.txt") == "TEST_CRC" - mock_instance.bucket.assert_called_with("bucket_name") + assert ( + mock_instance.bucket.call_count >= 1 + ) # There is a second call in get_versioned_blob + first_call = mock_instance.bucket.call_args_list[0] + assert first_call == call("bucket_name") bucket.blob.assert_called_with("content.txt") blob.reload.assert_called() From 84456f4fc4ef53d1ed7496166da9590ac0c47dc7 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 21:12:16 +0100 Subject: [PATCH 24/36] Address comment --- policyengine/utils/data_download.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine/utils/data_download.py b/policyengine/utils/data_download.py index 307d2794..fd16adcf 100644 --- a/policyengine/utils/data_download.py +++ b/policyengine/utils/data_download.py @@ -11,7 +11,7 @@ def download( filepath: str, gcs_bucket: str, version: Optional[str] = None, -) -> str | Tuple[str, str]: +) -> str: logging.info("Using Google Cloud Storage for download.") download_file_from_gcs( bucket_name=gcs_bucket, From 7885b0da177c1069be75b8f239fcca275afa54d9 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 21:12:28 +0100 Subject: [PATCH 25/36] Add to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 57a4e3e2..a210954a 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,4 @@ cython_debug/ !docs/**/*.ipynb **/*.h5 +**/*.csv From e728bf6da2c3c698883841156822c8c949604b8f Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 21:14:06 +0100 Subject: [PATCH 26/36] Fix tests --- tests/country/test_uk.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/tests/country/test_uk.py b/tests/country/test_uk.py index 801dfc4a..88bc9b66 100644 --- a/tests/country/test_uk.py +++ b/tests/country/test_uk.py @@ -1,3 +1,5 @@ +import pytest + def test_uk_macro_single(): from policyengine import Simulation @@ -26,8 +28,8 @@ def test_uk_macro_comparison(): def test_uk_macro_bad_package_versions_fail(): from policyengine import Simulation - try: - sim = Simulation( + with pytest.raises(ValueError): + Simulation( scope="macro", country="uk", reform={ @@ -35,18 +37,13 @@ def test_uk_macro_bad_package_versions_fail(): }, model_version="a", ) - raise ValueError( - "Simulation should have failed with a bad package version." - ) - except: - pass def test_uk_macro_bad_data_version_fails(): from policyengine import Simulation - try: - sim = Simulation( + with pytest.raises(ValueError): + Simulation( scope="macro", country="uk", reform={ @@ -54,8 +51,4 @@ def test_uk_macro_bad_data_version_fails(): }, data_version="a", ) - raise ValueError( - "Simulation should have failed with a bad data version." - ) - except: - pass + From 20ae78bac7b48094fd509b7dadc587a42094de20 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 21:15:10 +0100 Subject: [PATCH 27/36] Nit --- tests/utils/data/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/utils/data/conftest.py b/tests/utils/data/conftest.py index 3b204528..7f15dea5 100644 --- a/tests/utils/data/conftest.py +++ b/tests/utils/data/conftest.py @@ -12,7 +12,6 @@ def given_stored_data(self, data: str, crc: str): data.encode(), crc, ) - print("Setting latest version") self.mock_simple_storage_client._get_latest_version.return_value = ( "1.2.3" ) From e9f528c8529ab2911312a644c75c65ffea52ca91 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 21:15:45 +0100 Subject: [PATCH 28/36] Nit 2 --- tests/utils/data/conftest.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/utils/data/conftest.py b/tests/utils/data/conftest.py index 7f15dea5..f3798b6f 100644 --- a/tests/utils/data/conftest.py +++ b/tests/utils/data/conftest.py @@ -1,6 +1,7 @@ import pytest from unittest.mock import patch +VALID_VERSION = "1.2.3" class MockedStorageSupport: def __init__(self, mock_simple_storage_client): @@ -13,7 +14,7 @@ def given_stored_data(self, data: str, crc: str): crc, ) self.mock_simple_storage_client._get_latest_version.return_value = ( - "1.2.3" + VALID_VERSION ) def given_crc_changes_on_download( @@ -25,7 +26,7 @@ def given_crc_changes_on_download( download_crc, ) self.mock_simple_storage_client._get_latest_version.return_value = ( - "1.2.3" + VALID_VERSION ) From 2edf16ba87b3e2d895b364a55d1143727869234d Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 21:19:39 +0100 Subject: [PATCH 29/36] Fix test --- policyengine/utils/data/simplified_google_storage_client.py | 3 +-- tests/country/test_uk.py | 2 +- tests/utils/data/conftest.py | 1 + tests/utils/data/test_simplified_google_storage_client.py | 6 +----- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/policyengine/utils/data/simplified_google_storage_client.py b/policyengine/utils/data/simplified_google_storage_client.py index 37e1b46d..22f065e2 100644 --- a/policyengine/utils/data/simplified_google_storage_client.py +++ b/policyengine/utils/data/simplified_google_storage_client.py @@ -46,8 +46,7 @@ def crc32c( get the current CRC of the specified blob. None if it doesn't exist. """ logger.debug(f"Getting crc for {bucket_name}, {key}") - bucket = self.client.bucket(bucket_name) - blob = self.get_versioned_blob(bucket.name, key, version) + blob = self.get_versioned_blob(bucket_name, key, version) blob.reload() logger.debug(f"Crc is {blob.crc32c}") diff --git a/tests/country/test_uk.py b/tests/country/test_uk.py index 88bc9b66..c28f1e59 100644 --- a/tests/country/test_uk.py +++ b/tests/country/test_uk.py @@ -1,5 +1,6 @@ import pytest + def test_uk_macro_single(): from policyengine import Simulation @@ -51,4 +52,3 @@ def test_uk_macro_bad_data_version_fails(): }, data_version="a", ) - diff --git a/tests/utils/data/conftest.py b/tests/utils/data/conftest.py index f3798b6f..19cbcd9a 100644 --- a/tests/utils/data/conftest.py +++ b/tests/utils/data/conftest.py @@ -3,6 +3,7 @@ VALID_VERSION = "1.2.3" + class MockedStorageSupport: def __init__(self, mock_simple_storage_client): self.mock_simple_storage_client = mock_simple_storage_client diff --git a/tests/utils/data/test_simplified_google_storage_client.py b/tests/utils/data/test_simplified_google_storage_client.py index b9df13aa..229fdfc3 100644 --- a/tests/utils/data/test_simplified_google_storage_client.py +++ b/tests/utils/data/test_simplified_google_storage_client.py @@ -17,11 +17,7 @@ def test_crc32c__gets_crc(self, mock_client_class): client = SimplifiedGoogleStorageClient() assert client.crc32c("bucket_name", "content.txt") == "TEST_CRC" - assert ( - mock_instance.bucket.call_count >= 1 - ) # There is a second call in get_versioned_blob - first_call = mock_instance.bucket.call_args_list[0] - assert first_call == call("bucket_name") + mock_instance.bucket.assert_called_with("bucket_name") bucket.blob.assert_called_with("content.txt") blob.reload.assert_called() From 8e72495df420494552438de0e5bbdad4f2e7db72 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 21:25:56 +0100 Subject: [PATCH 30/36] Add tests --- .../test_simplified_google_storage_client.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/utils/data/test_simplified_google_storage_client.py b/tests/utils/data/test_simplified_google_storage_client.py index 229fdfc3..62636e58 100644 --- a/tests/utils/data/test_simplified_google_storage_client.py +++ b/tests/utils/data/test_simplified_google_storage_client.py @@ -40,3 +40,60 @@ def test_download__downloads_content(self, mock_client_class): mock_instance.bucket.assert_called_with("bucket") bucket.blob.assert_called_with("blob.txt") + + @patch( + "policyengine.utils.data.simplified_google_storage_client.Client", + autospec=True, + ) + def test_get_latest_version__returns_version_from_metadata(self, mock_client_class): + mock_instance = mock_client_class.return_value + bucket = mock_instance.get_bucket.return_value + blob = bucket.get_blob.return_value + + # Test case where metadata exists with version + blob.metadata = {"version": "v1.2.3"} + + client = SimplifiedGoogleStorageClient() + result = client._get_latest_version("test_bucket", "test_key") + + assert result == "v1.2.3" + mock_instance.get_bucket.assert_called_with("test_bucket") + bucket.get_blob.assert_called_with("test_key") + + @patch( + "policyengine.utils.data.simplified_google_storage_client.Client", + autospec=True, + ) + def test_get_latest_version__returns_none_when_no_metadata(self, mock_client_class): + mock_instance = mock_client_class.return_value + bucket = mock_instance.get_bucket.return_value + blob = bucket.get_blob.return_value + + # Test case where metadata is None + blob.metadata = None + + client = SimplifiedGoogleStorageClient() + result = client._get_latest_version("test_bucket", "test_key") + + assert result is None + mock_instance.get_bucket.assert_called_with("test_bucket") + bucket.get_blob.assert_called_with("test_key") + + @patch( + "policyengine.utils.data.simplified_google_storage_client.Client", + autospec=True, + ) + def test_get_latest_version__returns_none_when_no_version_in_metadata(self, mock_client_class): + mock_instance = mock_client_class.return_value + bucket = mock_instance.get_bucket.return_value + blob = bucket.get_blob.return_value + + # Test case where metadata exists but no version field + blob.metadata = {"other_field": "value"} + + client = SimplifiedGoogleStorageClient() + result = client._get_latest_version("test_bucket", "test_key") + + assert result is None + mock_instance.get_bucket.assert_called_with("test_bucket") + bucket.get_blob.assert_called_with("test_key") \ No newline at end of file From ec070853a71c9353618f9c06e0424fbe2d3624c0 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 21:32:23 +0100 Subject: [PATCH 31/36] Fix test --- .../data/simplified_google_storage_client.py | 2 ++ .../test_simplified_google_storage_client.py | 34 +++++++++++-------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/policyengine/utils/data/simplified_google_storage_client.py b/policyengine/utils/data/simplified_google_storage_client.py index 22f065e2..f15f337b 100644 --- a/policyengine/utils/data/simplified_google_storage_client.py +++ b/policyengine/utils/data/simplified_google_storage_client.py @@ -33,6 +33,8 @@ def get_versioned_blob( prefix=key, versions=True ) for v in versions: + if v.metadata is None: + continue # Skip blobs without metadata if v.metadata.get("version") == version: return v raise ValueError( diff --git a/tests/utils/data/test_simplified_google_storage_client.py b/tests/utils/data/test_simplified_google_storage_client.py index 62636e58..03313b10 100644 --- a/tests/utils/data/test_simplified_google_storage_client.py +++ b/tests/utils/data/test_simplified_google_storage_client.py @@ -40,22 +40,24 @@ def test_download__downloads_content(self, mock_client_class): mock_instance.bucket.assert_called_with("bucket") bucket.blob.assert_called_with("blob.txt") - + @patch( "policyengine.utils.data.simplified_google_storage_client.Client", autospec=True, ) - def test_get_latest_version__returns_version_from_metadata(self, mock_client_class): + def test_get_latest_version__returns_version_from_metadata( + self, mock_client_class + ): mock_instance = mock_client_class.return_value bucket = mock_instance.get_bucket.return_value blob = bucket.get_blob.return_value - + # Test case where metadata exists with version blob.metadata = {"version": "v1.2.3"} - + client = SimplifiedGoogleStorageClient() result = client._get_latest_version("test_bucket", "test_key") - + assert result == "v1.2.3" mock_instance.get_bucket.assert_called_with("test_bucket") bucket.get_blob.assert_called_with("test_key") @@ -64,17 +66,19 @@ def test_get_latest_version__returns_version_from_metadata(self, mock_client_cla "policyengine.utils.data.simplified_google_storage_client.Client", autospec=True, ) - def test_get_latest_version__returns_none_when_no_metadata(self, mock_client_class): + def test_get_latest_version__returns_none_when_no_metadata( + self, mock_client_class + ): mock_instance = mock_client_class.return_value bucket = mock_instance.get_bucket.return_value blob = bucket.get_blob.return_value - + # Test case where metadata is None blob.metadata = None - + client = SimplifiedGoogleStorageClient() result = client._get_latest_version("test_bucket", "test_key") - + assert result is None mock_instance.get_bucket.assert_called_with("test_bucket") bucket.get_blob.assert_called_with("test_key") @@ -83,17 +87,19 @@ def test_get_latest_version__returns_none_when_no_metadata(self, mock_client_cla "policyengine.utils.data.simplified_google_storage_client.Client", autospec=True, ) - def test_get_latest_version__returns_none_when_no_version_in_metadata(self, mock_client_class): + def test_get_latest_version__returns_none_when_no_version_in_metadata( + self, mock_client_class + ): mock_instance = mock_client_class.return_value bucket = mock_instance.get_bucket.return_value blob = bucket.get_blob.return_value - + # Test case where metadata exists but no version field blob.metadata = {"other_field": "value"} - + client = SimplifiedGoogleStorageClient() result = client._get_latest_version("test_bucket", "test_key") - + assert result is None mock_instance.get_bucket.assert_called_with("test_bucket") - bucket.get_blob.assert_called_with("test_key") \ No newline at end of file + bucket.get_blob.assert_called_with("test_key") From 925ef2d2c36f6347f09fe056df8a5fc305857d3a Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 21:40:40 +0100 Subject: [PATCH 32/36] minor bug fixes --- .github/workflows/any_changes.yaml | 4 +++ docs/concepts/simulation.ipynb | 36 ++++++++++++++++--- .../calculate_economy_comparison.py | 7 ++-- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/any_changes.yaml b/.github/workflows/any_changes.yaml index 84f81348..9ba50acf 100644 --- a/.github/workflows/any_changes.yaml +++ b/.github/workflows/any_changes.yaml @@ -20,6 +20,10 @@ jobs: uses: actions/setup-python@v2 with: python-version: '3.11' + - uses: "google-github-actions/auth@v2" + with: + workload_identity_provider: "projects/322898545428/locations/global/workloadIdentityPools/policyengine-research-id-pool/providers/prod-github-provider" + service_account: "policyengine-research@policyengine-research.iam.gserviceaccount.com" - name: Install package run: uv pip install .[dev] --system diff --git a/docs/concepts/simulation.ipynb b/docs/concepts/simulation.ipynb index 2aedd524..82635f8b 100644 --- a/docs/concepts/simulation.ipynb +++ b/docs/concepts/simulation.ipynb @@ -22,13 +22,39 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/nikhilwoodruff/policyengine/policyengine.py/.venv/lib/python3.11/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n", + "INFO:root:Using Google Cloud Storage for download.\n", + "WARNING:root:No metadata found for blob, so it has no version attached.\n", + "WARNING:root:No version specified for policyengine-uk-data-private, enhanced_frs_2022_23.h5. Using latest version: None\n", + "INFO:policyengine.utils.data.caching_google_storage_client:Syncing policyengine-uk-data-private, enhanced_frs_2022_23.h5, None to cache\n", + "INFO:policyengine.utils.data.simplified_google_storage_client:Downloading policyengine-uk-data-private, enhanced_frs_2022_23.h5\n", + "INFO:policyengine.utils.data.caching_google_storage_client:Copying downloaded data for policyengine-uk-data-private, enhanced_frs_2022_23.h5 to enhanced_frs_2022_23.h5\n", + "INFO:root:Using Google Cloud Storage for download.\n", + "WARNING:root:No metadata found for blob, so it has no version attached.\n", + "WARNING:root:No version specified for policyengine-uk-data-private, parliamentary_constituency_weights.h5. Using latest version: None\n", + "INFO:policyengine.utils.data.caching_google_storage_client:Syncing policyengine-uk-data-private, parliamentary_constituency_weights.h5, None to cache\n", + "INFO:policyengine.utils.data.simplified_google_storage_client:Downloading policyengine-uk-data-private, parliamentary_constituency_weights.h5\n", + "INFO:policyengine.utils.data.caching_google_storage_client:Copying downloaded data for policyengine-uk-data-private, parliamentary_constituency_weights.h5 to parliamentary_constituency_weights.h5\n", + "INFO:root:Using Google Cloud Storage for download.\n", + "WARNING:root:No metadata found for blob, so it has no version attached.\n", + "WARNING:root:No version specified for policyengine-uk-data-private, constituencies_2024.csv. Using latest version: None\n", + "INFO:policyengine.utils.data.caching_google_storage_client:Syncing policyengine-uk-data-private, constituencies_2024.csv, None to cache\n", + "INFO:policyengine.utils.data.simplified_google_storage_client:Downloading policyengine-uk-data-private, constituencies_2024.csv\n", + "INFO:policyengine.utils.data.caching_google_storage_client:Copying downloaded data for policyengine-uk-data-private, constituencies_2024.csv to constituencies_2024.csv\n" + ] + }, { "data": { "text/plain": [ - "EconomyComparison(fiscal=FiscalComparison(baseline=FiscalSummary(tax_revenue=658911285719.5891, federal_tax=658911285719.5891, state_tax=0.0, government_spending=349760026840.3932, tax_benefit_programs={'income_tax': 333376287037.05945, 'national_insurance': 52985626776.773834, 'ni_employer': 126330649370.35953, 'vat': 211671832822.39133, 'council_tax': 49007055050.00724, 'fuel_duty': 26506672341.204205, 'tax_credits': -34929879.49872104, 'universal_credit': -73459549194.97665, 'child_benefit': -14311471487.935827, 'state_pension': -132795868621.44594, 'pension_credit': -6252358021.417119}, household_net_income=1566030461192.7288), reform=FiscalSummary(tax_revenue=658911285719.5891, federal_tax=658911285719.5891, state_tax=0.0, government_spending=349760026840.3932, tax_benefit_programs={'income_tax': 333376287037.05945, 'national_insurance': 52985626776.773834, 'ni_employer': 126330649370.35953, 'vat': 211671832822.39133, 'council_tax': 49007055050.00724, 'fuel_duty': 26506672341.204205, 'tax_credits': -34929879.49872104, 'universal_credit': -73459549194.97665, 'child_benefit': -14311471487.935827, 'state_pension': -132795868621.44594, 'pension_credit': -6252358021.417119}, household_net_income=1566030461192.7288), change=FiscalSummary(tax_revenue=0.0, federal_tax=0.0, state_tax=0.0, government_spending=0.0, tax_benefit_programs={'income_tax': 0.0, 'national_insurance': 0.0, 'ni_employer': 0.0, 'vat': 0.0, 'council_tax': 0.0, 'fuel_duty': 0.0, 'tax_credits': 0.0, 'universal_credit': 0.0, 'child_benefit': 0.0, 'state_pension': 0.0, 'pension_credit': 0.0}, household_net_income=0.0)), inequality=InequalitySummary(gini=0.0, top_10_share=0.0, top_1_share=0.0))" + "EconomyComparison(country_package_version='2.24.1', budget=BudgetaryImpact(budgetary_impact=0.0, tax_revenue_impact=0.0, state_tax_revenue_impact=0.0, benefit_spending_impact=0.0, households=34067959.16713403, baseline_net_income=1594652644809.1484), detailed_budget={'income_tax': ProgramSpecificImpact(baseline=333485558893.2562, reform=333485558893.2562, difference=0.0), 'national_insurance': ProgramSpecificImpact(baseline=58106797201.72149, reform=58106797201.72149, difference=0.0), 'vat': ProgramSpecificImpact(baseline=217779970340.45303, reform=217779970340.45303, difference=0.0), 'council_tax': ProgramSpecificImpact(baseline=55919103106.82793, reform=55919103106.82793, difference=0.0), 'fuel_duty': ProgramSpecificImpact(baseline=29274240650.308544, reform=29274240650.308544, difference=0.0), 'tax_credits': ProgramSpecificImpact(baseline=-218647286.01696286, reform=-218647286.01696286, difference=0.0), 'universal_credit': ProgramSpecificImpact(baseline=-75937544684.01125, reform=-75937544684.01125, difference=0.0), 'child_benefit': ProgramSpecificImpact(baseline=-15904774022.820269, reform=-15904774022.820269, difference=0.0), 'state_pension': ProgramSpecificImpact(baseline=-136493500361.4934, reform=-136493500361.4934, difference=0.0), 'pension_credit': ProgramSpecificImpact(baseline=-6883460152.410097, reform=-6883460152.410097, difference=0.0), 'ni_employer': ProgramSpecificImpact(baseline=132081317376.75456, reform=132081317376.75456, difference=0.0)}, decile=DecileImpact(relative={1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0, 10: 0.0}, average={1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0, 10: 0.0}), inequality=InequalityImpact(gini=BaselineReformValues(baseline=0.35548246480717477, reform=0.35548246480717477), top_10_pct_share=BaselineReformValues(baseline=0.2761383438603205, reform=0.2761383438603205), top_1_pct_share=BaselineReformValues(baseline=0.07844282257306669, reform=0.07844282257306669)), poverty=PovertyImpact(poverty=AgeGroupBaselineReformValues(child=BaselineReformValues(baseline=0.1806112019067216, reform=0.1806112019067216), adult=BaselineReformValues(baseline=0.12147020231142763, reform=0.12147020231142763), senior=BaselineReformValues(baseline=0.08693354854833081, reform=0.08693354854833081), all=BaselineReformValues(baseline=0.12722600403258946, reform=0.12722600403258946)), deep_poverty=AgeGroupBaselineReformValues(child=BaselineReformValues(baseline=0.05188484608564893, reform=0.05188484608564893), adult=BaselineReformValues(baseline=0.03456228764618939, reform=0.03456228764618939), senior=BaselineReformValues(baseline=0.004885010754390452, reform=0.004885010754390452), all=BaselineReformValues(baseline=0.03250612463984824, reform=0.03250612463984824))), poverty_by_gender=PovertyGenderBreakdown(poverty=GenderBaselineReformValues(male=BaselineReformValues(baseline=0.12531502377752102, reform=0.12531502377752102), female=BaselineReformValues(baseline=0.1290056618478023, reform=0.1290056618478023)), deep_poverty=GenderBaselineReformValues(male=BaselineReformValues(baseline=0.03235609600210336, reform=0.03235609600210336), female=BaselineReformValues(baseline=0.03264584331928527, reform=0.03264584331928527))), poverty_by_race=None, intra_decile=IntraDecileImpact(deciles={'Lose more than 5%': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 'Lose less than 5%': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 'No change': [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 'Gain less than 5%': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 'Gain more than 5%': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}, all={'Lose more than 5%': 0.0, 'Lose less than 5%': 0.0, 'No change': 1.0, 'Gain less than 5%': 0.0, 'Gain more than 5%': 0.0}), wealth_decile=WealthDecileImpactWithValues(relative={1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0, 10: 0.0}, average={1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0, 10: 0.0}), intra_wealth_decile=IntraWealthDecileImpactWithValues(deciles={'Lose more than 5%': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 'Lose less than 5%': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 'No change': [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0], 'Gain less than 5%': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 'Gain more than 5%': [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}, all={'Lose more than 5%': 0.0, 'Lose less than 5%': 0.0, 'No change': 1.0, 'Gain less than 5%': 0.0, 'Gain more than 5%': 0.0}), labor_supply_response=LaborSupplyResponse(substitution_lsr=0.0, income_lsr=0.0, relative_lsr={'income': 0.0, 'substitution': 0.0}, total_change=0.0, revenue_change=0.0, decile={'average': {'income': {-1: 0.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0, 10: 0.0}, 'substitution': {-1: 0.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0, 10: 0.0}}, 'relative': {'income': {1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0, 10: 0.0}, 'substitution': {1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0, 7: 0.0, 8: 0.0, 9: 0.0, 10: 0.0}}}, hours=HoursResponse(baseline=0.0, reform=0.0, change=0.0, income_effect=0.0, substitution_effect=0.0)), constituency_impact=UKConstituencyBreakdownWithValues(by_constituency={'Aldershot': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-40), 'Aldridge-Brownhills': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-30), 'Altrincham and Sale West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-25), 'Amber Valley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-27), 'Arundel and South Downs': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-44), 'Ashfield': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-27), 'Ashford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=72, y=-42), 'Ashton-under-Lyne': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-23), 'Aylesbury': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-35), 'Banbury': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-33), 'Barking': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-38), 'Barnsley North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-23), 'Barnsley South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-23), 'Barrow and Furness': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-16), 'Basildon and Billericay': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-34), 'Basingstoke': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-39), 'Bassetlaw': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-26), 'Bath': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-40), 'Battersea': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-41), 'Beaconsfield': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-37), 'Beckenham and Penge': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-43), 'Bedford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-32), 'Bermondsey and Old Southwark': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-40), 'Bethnal Green and Stepney': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-39), 'Beverley and Holderness': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-22), 'Bexhill and Battle': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=70, y=-44), 'Bexleyheath and Crayford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-39), 'Bicester and Woodstock': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-34), 'Birkenhead': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-27), 'Birmingham Edgbaston': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-33), 'Birmingham Erdington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-31), 'Birmingham Hall Green and Moseley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-32), 'Birmingham Hodge Hill and Solihull North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-31), 'Birmingham Ladywood': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-32), 'Birmingham Northfield': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-34), 'Birmingham Perry Barr': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-31), 'Birmingham Selly Oak': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-33), 'Birmingham Yardley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-32), 'Bishop Auckland': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-14), 'Blackburn': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-19), 'Blackley and Middleton South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-23), 'Blackpool North and Fleetwood': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-18), 'Blackpool South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-18), 'Blaydon and Consett': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-14), 'Blyth and Ashington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-12), 'Bognor Regis and Littlehampton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-44), 'Bolsover': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-26), 'Bolton North East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-21), 'Bolton South and Walkden': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-22), 'Bolton West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-21), 'Bootle': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-22), 'Boston and Skegness': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-26), 'Bournemouth East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-43), 'Bournemouth West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-42), 'Bracknell': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-39), 'Bradford East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-20), 'Bradford South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-21), 'Bradford West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-20), 'Braintree': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-31), 'Brent East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-38), 'Brent West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-38), 'Brentford and Isleworth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-40), 'Brentwood and Ongar': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-33), 'Bridgwater': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-41), 'Bridlington and The Wolds': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-20), 'Brigg and Immingham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-24), 'Brighton Kemptown and Peacehaven': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-45), 'Brighton Pavilion': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-44), 'Bristol Central': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-38), 'Bristol East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-38), 'Bristol North East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-37), 'Bristol North West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-38), 'Bristol South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-39), 'Broadland and Fakenham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-27), 'Bromley and Biggin Hill': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-42), 'Bromsgrove': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-33), 'Broxbourne': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-35), 'Broxtowe': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-27), 'Buckingham and Bletchley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-34), 'Burnley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-19), 'Burton and Uttoxeter': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-28), 'Bury North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-21), 'Bury South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-22), 'Bury St Edmunds and Stowmarket': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-31), 'Calder Valley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-20), 'Camborne and Redruth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=43, y=-45), 'Cambridge': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-30), 'Cannock Chase': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-29), 'Canterbury': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=71, y=-41), 'Carlisle': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-14), 'Carshalton and Wallington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-43), 'Castle Point': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-36), 'Central Devon': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=47, y=-42), 'Central Suffolk and North Ipswich': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-29), 'Chatham and Aylesford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-40), 'Cheadle': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-26), 'Chelmsford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-33), 'Chelsea and Fulham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-40), 'Cheltenham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-36), 'Chesham and Amersham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-36), 'Chester North and Neston': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-28), 'Chester South and Eddisbury': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-27), 'Chesterfield': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-26), 'Chichester': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-44), 'Chingford and Woodford Green': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-35), 'Chippenham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-39), 'Chipping Barnet': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-36), 'Chorley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-20), 'Christchurch': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-42), 'Cities of London and Westminster': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-40), 'City of Durham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-16), 'Clacton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-32), 'Clapham and Brixton Hill': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-42), 'Colchester': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-32), 'Colne Valley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-23), 'Congleton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-27), 'Corby and East Northamptonshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-30), 'Coventry East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-33), 'Coventry North West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-33), 'Coventry South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-34), 'Cramlington and Killingworth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-12), 'Crawley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-44), 'Crewe and Nantwich': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-27), 'Croydon East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-42), 'Croydon South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-43), 'Croydon West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-43), 'Dagenham and Rainham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-37), 'Darlington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-17), 'Dartford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-40), 'Daventry': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-32), 'Derby North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-28), 'Derby South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-28), 'Derbyshire Dales': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-26), 'Dewsbury and Batley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-22), 'Didcot and Wantage': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-38), 'Doncaster Central': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-23), 'Doncaster East and the Isle of Axholme': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-23), 'Doncaster North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-22), 'Dorking and Horley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-43), 'Dover and Deal': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=72, y=-41), 'Droitwich and Evesham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-36), 'Dudley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-31), 'Dulwich and West Norwood': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-42), 'Dunstable and Leighton Buzzard': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-33), 'Ealing Central and Acton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-39), 'Ealing North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-38), 'Ealing Southall': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-39), 'Earley and Woodley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-36), 'Easington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-16), 'East Grinstead and Uckfield': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-43), 'East Ham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-38), 'East Hampshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-41), 'East Surrey': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-43), 'East Thanet': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=71, y=-39), 'East Wiltshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-41), 'East Worthing and Shoreham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-44), 'Eastbourne': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-45), 'Eastleigh': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-41), 'Edmonton and Winchmore Hill': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-36), 'Ellesmere Port and Bromborough': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-27), 'Eltham and Chislehurst': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-41), 'Ely and East Cambridgeshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-30), 'Enfield North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-35), 'Epping Forest': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-35), 'Epsom and Ewell': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-43), 'Erewash': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-28), 'Erith and Thamesmead': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-40), 'Esher and Walton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-42), 'Exeter': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-42), 'Exmouth and Exeter East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-43), 'Fareham and Waterlooville': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-43), 'Farnham and Bordon': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-42), 'Faversham and Mid Kent': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=71, y=-40), 'Feltham and Heston': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-40), 'Filton and Bradley Stoke': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-37), 'Finchley and Golders Green': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-37), 'Folkestone and Hythe': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=71, y=-42), 'Forest of Dean': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-35), 'Frome and East Somerset': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-41), 'Fylde': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-19), 'Gainsborough': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-25), 'Gateshead Central and Whickham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-15), 'Gedling': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-28), 'Gillingham and Rainham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=70, y=-40), 'Glastonbury and Somerton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-41), 'Gloucester': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-35), 'Godalming and Ash': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-42), 'Goole and Pocklington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-21), 'Gorton and Denton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-24), 'Gosport': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-43), 'Grantham and Bourne': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-28), 'Gravesham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-39), 'Great Grimsby and Cleethorpes': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-24), 'Great Yarmouth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-27), 'Greenwich and Woolwich': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-40), 'Guildford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-41), 'Hackney North and Stoke Newington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-38), 'Hackney South and Shoreditch': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-39), 'Halesowen': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-33), 'Halifax': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-21), 'Hamble Valley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-43), 'Hammersmith and Chiswick': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-39), 'Hampstead and Highgate': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-38), 'Harborough, Oadby and Wigston': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-31), 'Harlow': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-32), 'Harpenden and Berkhamsted': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-34), 'Harrogate and Knaresborough': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-18), 'Harrow East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-37), 'Harrow West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-37), 'Hartlepool': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-16), 'Harwich and North Essex': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-31), 'Hastings and Rye': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=70, y=-43), 'Havant': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-44), 'Hayes and Harlington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-38), 'Hazel Grove': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-25), 'Hemel Hempstead': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-34), 'Hendon': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-36), 'Henley and Thame': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-35), 'Hereford and South Herefordshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-34), 'Herne Bay and Sandwich': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=72, y=-40), 'Hertford and Stortford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-32), 'Hertsmere': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-34), 'Hexham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-13), 'Heywood and Middleton North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-20), 'High Peak': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-25), 'Hinckley and Bosworth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-30), 'Hitchin': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-32), 'Holborn and St Pancras': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-39), 'Honiton and Sidmouth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-43), 'Hornchurch and Upminster': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-37), 'Hornsey and Friern Barnet': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-36), 'Horsham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-44), 'Houghton and Sunderland South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-15), 'Hove and Portslade': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-44), 'Huddersfield': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-22), 'Huntingdon': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-31), 'Hyndburn': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-19), 'Ilford North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-36), 'Ilford South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-37), 'Ipswich': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-30), 'Isle of Wight East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-45), 'Isle of Wight West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-45), 'Islington North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-38), 'Islington South and Finsbury': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-39), 'Jarrow and Gateshead East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-14), 'Keighley and Ilkley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-19), 'Kenilworth and Southam': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-34), 'Kensington and Bayswater': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-39), 'Kettering': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-30), 'Kingston and Surbiton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-42), 'Kingston upon Hull East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-22), 'Kingston upon Hull North and Cottingham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-21), 'Kingston upon Hull West and Haltemprice': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-22), 'Kingswinford and South Staffordshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-30), 'Knowsley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-23), 'Lancaster and Wyre': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-18), 'Leeds Central and Headingley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-20), 'Leeds East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-20), 'Leeds North East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-19), 'Leeds North West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-19), 'Leeds South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-21), 'Leeds South West and Morley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-21), 'Leeds West and Pudsey': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-20), 'Leicester East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-30), 'Leicester South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-31), 'Leicester West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-31), 'Leigh and Atherton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-25), 'Lewes': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-45), 'Lewisham East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-42), 'Lewisham North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-40), 'Lewisham West and East Dulwich': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-41), 'Leyton and Wanstead': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-37), 'Lichfield': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-29), 'Lincoln': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-25), 'Liverpool Garston': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-25), 'Liverpool Riverside': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-24), 'Liverpool Walton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-23), 'Liverpool Wavertree': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-25), 'Liverpool West Derby': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-24), 'Loughborough': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-30), 'Louth and Horncastle': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-25), 'Lowestoft': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-28), 'Luton North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-33), 'Luton South and South Bedfordshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-34), 'Macclesfield': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-26), 'Maidenhead': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-36), 'Maidstone and Malling': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-41), 'Makerfield': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-22), 'Maldon': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-33), 'Manchester Central': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-24), 'Manchester Rusholme': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-25), 'Manchester Withington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-26), 'Mansfield': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-27), 'Melksham and Devizes': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-40), 'Melton and Syston': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-29), 'Meriden and Solihull East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-33), 'Mid Bedfordshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-32), 'Mid Buckinghamshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-35), 'Mid Cheshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-27), 'Mid Derbyshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-27), 'Mid Dorset and North Poole': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-43), 'Mid Leicestershire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-31), 'Mid Norfolk': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-28), 'Mid Sussex': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-43), 'Middlesbrough and Thornaby East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-17), 'Middlesbrough South and East Cleveland': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-17), 'Milton Keynes Central': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-34), 'Milton Keynes North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-33), 'Mitcham and Morden': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-43), 'Morecambe and Lunesdale': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-17), 'New Forest East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-43), 'New Forest West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-43), 'Newark': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-26), 'Newbury': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-37), 'Newcastle upon Tyne Central and West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-13), 'Newcastle upon Tyne East and Wallsend': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-14), 'Newcastle upon Tyne North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-13), 'Newcastle-under-Lyme': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-28), 'Newton Abbot': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=47, y=-43), 'Newton Aycliffe and Spennymoor': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-16), 'Normanton and Hemsworth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-23), 'North Bedfordshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-31), 'North Cornwall': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=45, y=-43), 'North Cotswolds': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-37), 'North Devon': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=46, y=-41), 'North Dorset': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-42), 'North Durham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-15), 'North East Cambridgeshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-29), 'North East Derbyshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-26), 'North East Hampshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-38), 'North East Hertfordshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-32), 'North East Somerset and Hanham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-39), 'North Herefordshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-34), 'North Norfolk': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-27), 'North Northumberland': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-12), 'North Shropshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-29), 'North Somerset': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-39), 'North Warwickshire and Bedworth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-32), 'North West Cambridgeshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-30), 'North West Essex': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-31), 'North West Hampshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-39), 'North West Leicestershire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-29), 'North West Norfolk': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-28), 'Northampton North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-32), 'Northampton South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-33), 'Norwich North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-28), 'Norwich South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-29), 'Nottingham East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-29), 'Nottingham North and Kimberley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-28), 'Nottingham South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-29), 'Nuneaton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-31), 'Old Bexley and Sidcup': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-41), 'Oldham East and Saddleworth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-22), 'Oldham West, Chadderton and Royton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-22), 'Orpington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-43), 'Ossett and Denby Dale': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-22), 'Oxford East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-34), 'Oxford West and Abingdon': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-35), 'Peckham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-41), 'Pendle and Clitheroe': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-18), 'Penistone and Stocksbridge': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-23), 'Penrith and Solway': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-15), 'Peterborough': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-29), 'Plymouth Moor View': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=46, y=-43), 'Plymouth Sutton and Devonport': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=47, y=-44), 'Pontefract, Castleford and Knottingley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-22), 'Poole': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-43), 'Poplar and Limehouse': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-39), 'Portsmouth North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-43), 'Portsmouth South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-44), 'Preston': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-19), 'Putney': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-41), \"Queen's Park and Maida Vale\": UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-40), 'Rawmarsh and Conisbrough': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-24), 'Rayleigh and Wickford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-34), 'Reading Central': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-37), 'Reading West and Mid Berkshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-36), 'Redcar': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-17), 'Redditch': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-35), 'Reigate': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-44), 'Ribble Valley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-18), 'Richmond and Northallerton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-18), 'Richmond Park': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-41), 'Rochdale': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-21), 'Rochester and Strood': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-39), 'Romford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-36), 'Romsey and Southampton North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-40), 'Rossendale and Darwen': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-20), 'Rother Valley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-25), 'Rotherham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-24), 'Rugby': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-32), 'Ruislip, Northwood and Pinner': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-36), 'Runcorn and Helsby': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-28), 'Runnymede and Weybridge': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-41), 'Rushcliffe': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-28), 'Rutland and Stamford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-29), 'Salford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-24), 'Salisbury': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-41), 'Scarborough and Whitby': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-19), 'Scunthorpe': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-24), 'Sefton Central': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-20), 'Selby': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-21), 'Sevenoaks': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-42), 'Sheffield Brightside and Hillsborough': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-24), 'Sheffield Central': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-25), 'Sheffield Hallam': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-24), 'Sheffield Heeley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-25), 'Sheffield South East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-25), 'Sherwood Forest': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-27), 'Shipley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-19), 'Shrewsbury': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-30), 'Sittingbourne and Sheppey': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=70, y=-39), 'Skipton and Ripon': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-18), 'Sleaford and North Hykeham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-26), 'Slough': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-37), 'Smethwick': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-32), 'Solihull West and Shirley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-34), 'South Basildon and East Thurrock': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-36), 'South Cambridgeshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-31), 'South Cotswolds': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-38), 'South Derbyshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-29), 'South Devon': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-45), 'South Dorset': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-44), 'South East Cornwall': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=46, y=-44), 'South Holland and The Deepings': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-27), 'South Leicestershire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-32), 'South Norfolk': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-29), 'South Northamptonshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-33), 'South Ribble': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-20), 'South Shields': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-14), 'South Shropshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-31), 'South Suffolk': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-30), 'South West Devon': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=47, y=-45), 'South West Hertfordshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-35), 'South West Norfolk': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-29), 'South West Wiltshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-41), 'Southampton Itchen': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-42), 'Southampton Test': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-42), 'Southend East and Rochford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-34), 'Southend West and Leigh': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-35), 'Southgate and Wood Green': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-35), 'Southport': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-19), 'Spelthorne': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-40), 'Spen Valley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-21), 'St Albans': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-34), 'St Austell and Newquay': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=45, y=-44), 'St Helens North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-21), 'St Helens South and Whiston': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-22), 'St Ives': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=43, y=-46), 'St Neots and Mid Cambridgeshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-31), 'Stafford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-28), 'Staffordshire Moorlands': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-27), 'Stalybridge and Hyde': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-24), 'Stevenage': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-33), 'Stockport': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-25), 'Stockton North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-16), 'Stockton West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-17), 'Stoke-on-Trent Central': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-28), 'Stoke-on-Trent North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-27), 'Stoke-on-Trent South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-29), 'Stone, Great Wyrley and Penkridge': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-28), 'Stourbridge': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-32), 'Stratford and Bow': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-38), 'Stratford-on-Avon': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-35), 'Streatham and Croydon North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-42), 'Stretford and Urmston': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-24), 'Stroud': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-37), 'Suffolk Coastal': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-29), 'Sunderland Central': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-15), 'Surrey Heath': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-39), 'Sussex Weald': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=70, y=-42), 'Sutton and Cheam': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-42), 'Sutton Coldfield': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-31), 'Swindon North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-39), 'Swindon South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-40), 'Tamworth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-30), 'Tatton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-26), 'Taunton and Wellington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-42), 'Telford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-29), 'Tewkesbury': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-36), 'The Wrekin': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-29), 'Thirsk and Malton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-18), 'Thornbury and Yate': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-36), 'Thurrock': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-36), 'Tipton and Wednesbury': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-31), 'Tiverton and Minehead': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=47, y=-41), 'Tonbridge': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-41), 'Tooting': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-42), 'Torbay': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-44), 'Torridge and Tavistock': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=46, y=-42), 'Tottenham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-37), 'Truro and Falmouth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=44, y=-45), 'Tunbridge Wells': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=69, y=-42), 'Twickenham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-41), 'Tynemouth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-13), 'Uxbridge and South Ruislip': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-37), 'Vauxhall and Camberwell Green': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-41), 'Wakefield and Rothwell': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=59, y=-22), 'Wallasey': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-27), 'Walsall and Bloxwich': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-30), 'Walthamstow': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-37), 'Warrington North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-23), 'Warrington South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-24), 'Warwick and Leamington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-35), 'Washington and Gateshead South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-15), 'Watford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-35), 'Waveney Valley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-28), 'Weald of Kent': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=70, y=-41), 'Wellingborough and Rushden': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=63, y=-30), 'Wells and Mendip Hills': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-40), 'Welwyn Hatfield': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=65, y=-33), 'West Bromwich': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-32), 'West Dorset': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-44), 'West Ham and Beckton': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=66, y=-38), 'West Lancashire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-21), 'West Suffolk': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=67, y=-30), 'West Worcestershire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-35), 'Westmorland and Lonsdale': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-15), 'Weston-super-Mare': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-40), 'Wetherby and Easingwold': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=62, y=-20), 'Whitehaven and Workington': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-16), 'Widnes and Halewood': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-26), 'Wigan': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-20), 'Wimbledon': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-41), 'Winchester': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-40), 'Windsor': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-38), 'Wirral West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-28), 'Witham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=68, y=-33), 'Witney': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=56, y=-35), 'Woking': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=57, y=-40), 'Wokingham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=55, y=-38), 'Wolverhampton North East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-29), 'Wolverhampton South East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-30), 'Wolverhampton West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-30), 'Worcester': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-34), 'Worsley and Eccles': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-23), 'Worthing West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=64, y=-44), 'Wycombe': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=58, y=-36), 'Wyre Forest': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-33), 'Wythenshawe and Sale East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-26), 'Yeovil': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-42), 'York Central': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=60, y=-19), 'York Outer': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=61, y=-18), 'Belfast East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=45, y=-17), 'Belfast North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=45, y=-16), 'Belfast South and Mid Down': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=45, y=-18), 'Belfast West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=44, y=-17), 'East Antrim': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=45, y=-15), 'East Londonderry': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=43, y=-15), 'Fermanagh and South Tyrone': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=42, y=-17), 'Foyle': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=42, y=-15), 'Lagan Valley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=44, y=-18), 'Mid Ulster': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=43, y=-16), 'Newry and Armagh': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=44, y=-19), 'North Antrim': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=44, y=-15), 'North Down': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=46, y=-16), 'South Antrim': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=44, y=-16), 'South Down': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=46, y=-18), 'Strangford': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=46, y=-17), 'Upper Bann': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=43, y=-18), 'West Tyrone': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=42, y=-16), 'East Renfrewshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-11), 'Na h-Eileanan an Iar': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=47, y=-2), 'Midlothian': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-11), 'North Ayrshire and Arran': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-10), 'Orkney and Shetland': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=0), 'Aberdeen North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-3), 'Aberdeen South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-4), 'Aberdeenshire North and Moray East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-3), 'Airdrie and Shotts': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-11), 'Alloa and Grangemouth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-7), 'Angus and Perthshire Glens': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-5), 'Arbroath and Broughty Ferry': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-5), 'Argyll, Bute and South Lochaber': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-5), 'Bathgate and Linlithgow': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-9), 'Caithness, Sutherland and Easter Ross': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-2), 'Coatbridge and Bellshill': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-12), 'Cowdenbeath and Kirkcaldy': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-7), 'Cumbernauld and Kirkintilloch': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-8), 'Dumfries and Galloway': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-13), 'Dumfriesshire, Clydesdale and Tweeddale': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-13), 'Dundee Central': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-6), 'Dunfermline and Dollar': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-7), 'East Kilbride and Strathaven': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-13), 'Edinburgh East and Musselburgh': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=54, y=-10), 'Edinburgh North and Leith': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-9), 'Edinburgh South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-10), 'Edinburgh South West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-10), 'Edinburgh West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-9), 'Falkirk': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-8), 'Glasgow East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-10), 'Glasgow North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-9), 'Glasgow North East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-9), 'Glasgow South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-11), 'Glasgow South West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-10), 'Glasgow West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-8), 'Glenrothes and Mid Fife': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-6), 'Gordon and Buchan': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-4), 'Hamilton and Clyde Valley': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-12), 'Inverclyde and Renfrewshire West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-8), 'Inverness, Skye and West Ross-shire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-3), 'Livingston': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-11), 'Lothian East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-11), 'Mid Dunbartonshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-7), 'Moray West, Nairn and Strathspey': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-4), 'Motherwell, Wishaw and Carluke': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=52, y=-12), 'North East Fife': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-6), 'Paisley and Renfrewshire North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-9), 'Paisley and Renfrewshire South': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-10), 'Perth and Kinross-shire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-5), 'Rutherglen': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-12), 'Stirling and Strathallan': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-6), 'West Dunbartonshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-7), 'Ayr, Carrick and Cumnock': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-13), 'Berwickshire, Roxburgh and Selkirk': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=53, y=-12), 'Central Ayrshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-12), 'Kilmarnock and Loudoun': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-13), 'West Aberdeenshire and Kincardine': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=51, y=-4), 'Aberafan Maesteg': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=46, y=-36), 'Alyn and Deeside': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-29), 'Bangor Aberconwy': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=47, y=-31), 'Blaenau Gwent and Rhymney': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-33), 'Brecon, Radnor and Cwm Tawe': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-32), 'Bridgend': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=46, y=-37), 'Caerfyrddin': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-32), 'Caerphilly': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-35), 'Cardiff East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-37), 'Cardiff North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-36), 'Cardiff South and Penarth': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-38), 'Cardiff West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=47, y=-37), 'Ceredigion Preseli': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-34), 'Clwyd East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-30), 'Clwyd North': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-30), 'Dwyfor Meirionnydd': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-31), 'Gower': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=44, y=-37), 'Llanelli': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=45, y=-36), 'Merthyr Tydfil and Aberdare': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-34), 'Mid and South Pembrokeshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=44, y=-36), 'Monmouthshire': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-36), 'Montgomeryshire and Glyndwr': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-31), 'Neath and Swansea East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=47, y=-35), 'Newport East': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-37), 'Newport West and Islwyn': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=49, y=-36), 'Pontypridd': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=48, y=-35), 'Rhondda and Ogmore': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=47, y=-36), 'Swansea West': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=45, y=-37), 'Torfaen': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-34), 'Vale of Glamorgan': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=47, y=-38), 'Wrexham': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=50, y=-30), 'Ynys Môn': UKConstituencyBreakdownByConstituency(average_household_income_change=0.0, relative_household_income_change=0.0, x=46, y=-29)}, outcomes_by_region={'uk': {'Gain more than 5%': 0, 'Gain less than 5%': 0, 'No change': 650, 'Lose less than 5%': 0, 'Lose more than 5%': 0}, 'england': {'Gain more than 5%': 0, 'Gain less than 5%': 0, 'No change': 543, 'Lose less than 5%': 0, 'Lose more than 5%': 0}, 'scotland': {'Gain more than 5%': 0, 'Gain less than 5%': 0, 'No change': 57, 'Lose less than 5%': 0, 'Lose more than 5%': 0}, 'wales': {'Gain more than 5%': 0, 'Gain less than 5%': 0, 'No change': 32, 'Lose less than 5%': 0, 'Lose more than 5%': 0}, 'northern_ireland': {'Gain more than 5%': 0, 'Gain less than 5%': 0, 'No change': 18, 'Lose less than 5%': 0, 'Lose more than 5%': 0}}), cliff_impact=None)" ] }, "execution_count": 1, @@ -79,7 +105,7 @@ "\n", "If you set `scope` to `macro`, you should provide either:\n", "\n", - "* A Hugging Face `.h5` dataset address in this format: `\"hf://policyengine/policyengine-us-data/cps_2023.h5\"` (`hf://owner/dataset-name/path.h5`).\n", + "* A Google Cloud `.h5` dataset address in this format: `\"gcs://policyengine-us-data/cps_2023.h5\"` (`gcs://bucket/path.h5`).\n", "* An instance of `policyengine_core.data.Dataset` (advanced).\n", "\n", "See `policyengine.constants` for the available datasets.\n", @@ -118,7 +144,7 @@ ], "metadata": { "kernelspec": { - "display_name": "base", + "display_name": ".venv", "language": "python", "name": "python3" }, @@ -132,7 +158,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.14" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py index bd1848f0..f90144c2 100644 --- a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py +++ b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py @@ -10,7 +10,6 @@ from policyengine.outputs.macro.single.calculate_single_economy import ( SingleEconomy, ) -from policyengine.utils.packages import get_country_package_version from typing import List, Dict @@ -784,7 +783,8 @@ class CliffImpact(BaseModel): class EconomyComparison(BaseModel): - country_package_version: str + model_version: str + data_version: str budget: BudgetaryImpact detailed_budget: DetailedBudgetaryImpact decile: DecileImpact @@ -847,7 +847,8 @@ def calculate_economy_comparison( cliff_impact = None return EconomyComparison( - country_package_version=get_country_package_version(country_id), + model_version=simulation.model_version, + data_version=simulation.data_version, budget=budgetary_impact_data, detailed_budget=detailed_budgetary_impact_data, decile=decile_impact_data, From ac1fd538f38e9b77d8b1e45a334b703634a08f4b Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 21:44:17 +0100 Subject: [PATCH 33/36] Add permissions to actions --- .github/workflows/any_changes.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/any_changes.yaml b/.github/workflows/any_changes.yaml index 9ba50acf..2f12633c 100644 --- a/.github/workflows/any_changes.yaml +++ b/.github/workflows/any_changes.yaml @@ -8,6 +8,9 @@ on: jobs: docs: + permissions: + contents: "read" + id-token: "write" name: Test documentation builds runs-on: ubuntu-latest steps: From 2208bcc44a71e25a970282d425820a4410dcfd9b Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 21:45:56 +0100 Subject: [PATCH 34/36] Fix test --- .../macro/comparison/calculate_economy_comparison.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py index f90144c2..27466e3d 100644 --- a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py +++ b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py @@ -10,7 +10,7 @@ from policyengine.outputs.macro.single.calculate_single_economy import ( SingleEconomy, ) -from typing import List, Dict +from typing import List, Dict, Optional class BudgetaryImpact(BaseModel): @@ -783,8 +783,10 @@ class CliffImpact(BaseModel): class EconomyComparison(BaseModel): - model_version: str - data_version: str + model_version: Optional[ + str + ] # Optional while some datasets have no tagged version. + data_version: Optional[str] budget: BudgetaryImpact detailed_budget: DetailedBudgetaryImpact decile: DecileImpact From 45bc6b7e0b79354dd985b492254cb7dfe48cd202 Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 22:14:56 +0100 Subject: [PATCH 35/36] Fix optional types --- .../macro/comparison/calculate_economy_comparison.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py index 27466e3d..bc53044c 100644 --- a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py +++ b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py @@ -783,10 +783,10 @@ class CliffImpact(BaseModel): class EconomyComparison(BaseModel): - model_version: Optional[ - str - ] # Optional while some datasets have no tagged version. - data_version: Optional[str] + model_version: Optional[str] = ( + None # Optional while some datasets have no tagged version. + ) + data_version: Optional[str] = None budget: BudgetaryImpact detailed_budget: DetailedBudgetaryImpact decile: DecileImpact From 60b20cff70ed2240d6e88af9850b0c831c1311ed Mon Sep 17 00:00:00 2001 From: Nikhil Woodruff Date: Mon, 26 May 2025 22:15:51 +0100 Subject: [PATCH 36/36] Use constant --- tests/utils/data/test_simplified_google_storage_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/utils/data/test_simplified_google_storage_client.py b/tests/utils/data/test_simplified_google_storage_client.py index 03313b10..b61fbf5f 100644 --- a/tests/utils/data/test_simplified_google_storage_client.py +++ b/tests/utils/data/test_simplified_google_storage_client.py @@ -2,6 +2,8 @@ import pytest from policyengine.utils.data import SimplifiedGoogleStorageClient +VALID_VERSION = "1.2.3" + class TestSimplifiedGoogleStorageClient: @patch( @@ -53,12 +55,12 @@ def test_get_latest_version__returns_version_from_metadata( blob = bucket.get_blob.return_value # Test case where metadata exists with version - blob.metadata = {"version": "v1.2.3"} + blob.metadata = {"version": VALID_VERSION} client = SimplifiedGoogleStorageClient() result = client._get_latest_version("test_bucket", "test_key") - assert result == "v1.2.3" + assert result == VALID_VERSION mock_instance.get_bucket.assert_called_with("test_bucket") bucket.get_blob.assert_called_with("test_key")