From a85675208abdbb3817c3ba69beddabc68b5ef0ac Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Wed, 26 Nov 2025 18:24:46 +0100 Subject: [PATCH 1/6] feat: Duplicate UK constituency breakdown for local areas --- .../calculate_economy_comparison.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py index b9852ea..1dccfdf 100644 --- a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py +++ b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py @@ -775,6 +775,74 @@ def uk_constituency_breakdown( return UKConstituencyBreakdownWithValues(**output) +class UKLocalAreaBreakdownByLocalArea(BaseModel): + average_household_income_change: float + relative_household_income_change: float + x: int + y: int + + +class UKLocalAreaBreakdownWithValues(BaseModel): + by_local_area: dict[str, UKLocalAreaBreakdownByLocalArea] + + +UKLocalAreaBreakdown = UKLocalAreaBreakdownWithValues | None + + +def uk_local_area_breakdown( + baseline: SingleEconomy, reform: SingleEconomy, country_id: str +) -> UKLocalAreaBreakdown: + if country_id != "uk": + return None + + output = { + "by_local_area": {}, + } + # Note: Country-level aggregation (outcomes_by_region) removed for local areas + baseline_hnet = baseline.household_net_income + reform_hnet = reform.household_net_income + + local_area_weights_path = download( + gcs_bucket="policyengine-uk-data-private", + filepath="local_authority_weights.h5", + ) + with h5py.File(local_area_weights_path, "r") as f: + weights = f["2025"][ + ... + ] # {2025: array(n_local_areas, n_households) where cell i, j is the weight of household record j in local area i} + + local_area_names_path = download( + gcs_bucket="policyengine-uk-data-private", + filepath="local_authorities_2021.csv", + ) + local_area_names = pd.read_csv( + str(local_area_names_path) + ) # columns code (local area code), name (local area name), x, y (geographic position) + + for i in range(len(local_area_names)): + name: str = local_area_names.iloc[i]["name"] + code: str = local_area_names.iloc[i]["code"] + weight: np.ndarray = weights[i] + baseline_income = MicroSeries(baseline_hnet, weights=weight) + reform_income = MicroSeries(reform_hnet, weights=weight) + average_household_income_change: float = ( + reform_income.sum() - baseline_income.sum() + ) / baseline_income.count() + percent_household_income_change: float = ( + reform_income.sum() / baseline_income.sum() - 1 + ) + output["by_local_area"][name] = { + "average_household_income_change": average_household_income_change, + "relative_household_income_change": percent_household_income_change, + "x": int(local_area_names.iloc[i]["x"]), # Geographic positions + "y": int(local_area_names.iloc[i]["y"]), + } + + # Note: Country-level aggregation and bucketing logic removed for local areas + + return UKLocalAreaBreakdownWithValues(**output) + + class CliffImpactInSimulation(BaseModel): cliff_gap: float cliff_share: float From bf5dc93f80fde8b3f3ddfda1aaf376c5847f45c8 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 11 Dec 2025 23:02:43 +0400 Subject: [PATCH 2/6] feat: Integrate economy comparison calculation into output structure --- .../outputs/macro/comparison/calculate_economy_comparison.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py index 1dccfdf..e2edb04 100644 --- a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py +++ b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py @@ -870,6 +870,7 @@ class EconomyComparison(BaseModel): intra_wealth_decile: IntraWealthDecileImpact labor_supply_response: LaborSupplyResponse constituency_impact: UKConstituencyBreakdown + local_authority_impact: UKLocalAreaBreakdown cliff_impact: CliffImpact | None @@ -900,6 +901,9 @@ def calculate_economy_comparison( constituency_impact_data: UKConstituencyBreakdown = ( uk_constituency_breakdown(baseline, reform, country_id) ) + local_authority_impact_data: UKLocalAreaBreakdown = ( + uk_local_area_breakdown(baseline, reform, country_id) + ) wealth_decile_impact_data = wealth_decile_impact( baseline, reform, country_id ) @@ -938,5 +942,6 @@ def calculate_economy_comparison( intra_wealth_decile=intra_wealth_decile_impact_data, labor_supply_response=labor_supply_response_data, constituency_impact=constituency_impact_data, + local_authority_impact=local_authority_impact_data, cliff_impact=cliff_impact, ) From f232d923c5a058af019a0d6faec7478c53dc2220 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 11 Dec 2025 23:32:59 +0400 Subject: [PATCH 3/6] chore: Changelog --- changelog_entry.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index e69de29..c8f896f 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -0,0 +1,4 @@ +- bump: minor + changes: + added: + - Local authority impacts \ No newline at end of file From 67446c8bb665301e763f72910b443f2d1437e0f2 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 11 Dec 2025 23:50:32 +0400 Subject: [PATCH 4/6] test: Update tests --- tests/country/test_uk.py | 104 +++++++++++++++++++++++++++++++++++++++ tests/utils/test_maps.py | 71 ++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 tests/utils/test_maps.py diff --git a/tests/country/test_uk.py b/tests/country/test_uk.py index c28f1e5..8fc2e9f 100644 --- a/tests/country/test_uk.py +++ b/tests/country/test_uk.py @@ -52,3 +52,107 @@ def test_uk_macro_bad_data_version_fails(): }, data_version="a", ) + + +def test_uk_macro_comparison_has_local_authority_impact(): + """Test that UK macro comparison includes local authority breakdown.""" + from policyengine import Simulation + + sim = Simulation( + scope="macro", + country="uk", + reform={ + "gov.hmrc.income_tax.allowances.personal_allowance.amount": 15_000, + }, + ) + + result = sim.calculate_economy_comparison() + + # Check local_authority_impact is present and not None for UK + assert hasattr(result, "local_authority_impact") + assert result.local_authority_impact is not None + + # Check structure + la_impact = result.local_authority_impact + assert hasattr(la_impact, "by_local_area") + assert len(la_impact.by_local_area) > 0 + + # Check each local authority has required fields + for name, data in la_impact.by_local_area.items(): + assert hasattr(data, "average_household_income_change") + assert hasattr(data, "relative_household_income_change") + assert hasattr(data, "x") + assert hasattr(data, "y") + assert isinstance(data.average_household_income_change, float) + assert isinstance(data.relative_household_income_change, float) + assert isinstance(data.x, int) + assert isinstance(data.y, int) + + +def test_uk_macro_comparison_constituency_and_local_authority_both_present(): + """Test that both constituency and local authority impacts are present.""" + from policyengine import Simulation + + sim = Simulation( + scope="macro", + country="uk", + reform={ + "gov.hmrc.income_tax.allowances.personal_allowance.amount": 15_000, + }, + ) + + result = sim.calculate_economy_comparison() + + # Both should be present for UK + assert result.constituency_impact is not None + assert result.local_authority_impact is not None + + # Constituencies should have outcomes_by_region, local authorities should not + assert hasattr(result.constituency_impact, "outcomes_by_region") + assert not hasattr(result.local_authority_impact, "outcomes_by_region") + + +def test_uk_simulation_local_authority_region_filter_by_code(): + """Test that simulation can filter by local authority code.""" + from policyengine import Simulation + + # Use a known local authority code (Westminster) + sim = Simulation( + scope="macro", + country="uk", + region="local_authority/E09000033", + ) + + # Should not raise an error + result = sim.calculate_single_economy() + assert result is not None + + +def test_uk_simulation_local_authority_region_filter_by_name(): + """Test that simulation can filter by local authority name.""" + from policyengine import Simulation + + # Use a known local authority name + sim = Simulation( + scope="macro", + country="uk", + region="local_authority/Westminster", + ) + + # Should not raise an error + result = sim.calculate_single_economy() + assert result is not None + + +def test_uk_simulation_invalid_local_authority_raises_error(): + """Test that invalid local authority raises ValueError.""" + from policyengine import Simulation + + sim = Simulation( + scope="macro", + country="uk", + region="local_authority/InvalidLocalAuthority123", + ) + + with pytest.raises(ValueError, match="Local authority .* not found"): + sim.calculate_single_economy() diff --git a/tests/utils/test_maps.py b/tests/utils/test_maps.py new file mode 100644 index 0000000..e44c0c4 --- /dev/null +++ b/tests/utils/test_maps.py @@ -0,0 +1,71 @@ +import pytest + + +def test_get_location_options_table_parliamentary_constituencies(): + """Test loading parliamentary constituencies location table.""" + from policyengine.utils.maps import get_location_options_table + + df = get_location_options_table("parliamentary_constituencies") + + assert len(df) > 0 + assert "name" in df.columns + assert "code" in df.columns + assert "x" in df.columns + assert "y" in df.columns + + +def test_get_location_options_table_local_authorities(): + """Test loading local authorities location table.""" + from policyengine.utils.maps import get_location_options_table + + df = get_location_options_table("local_authorities") + + assert len(df) > 0 + assert "name" in df.columns + assert "code" in df.columns + assert "x" in df.columns + assert "y" in df.columns + + +def test_plot_hex_map_local_authorities(): + """Test plotting hex map for local authorities.""" + from policyengine.utils.maps import get_location_options_table, plot_hex_map + + # Get local authority names + df = get_location_options_table("local_authorities") + + # Create dummy values for each local authority + value_by_area_name = {name: i * 0.01 for i, name in enumerate(df["name"])} + + fig = plot_hex_map(value_by_area_name, "local_authorities") + + # Check that a figure was returned + assert fig is not None + assert hasattr(fig, "data") + assert len(fig.data) > 0 + + +def test_plot_hex_map_parliamentary_constituencies(): + """Test plotting hex map for parliamentary constituencies.""" + from policyengine.utils.maps import get_location_options_table, plot_hex_map + + # Get constituency names + df = get_location_options_table("parliamentary_constituencies") + + # Create dummy values for each constituency + value_by_area_name = {name: i * 0.01 for i, name in enumerate(df["name"])} + + fig = plot_hex_map(value_by_area_name, "parliamentary_constituencies") + + # Check that a figure was returned + assert fig is not None + assert hasattr(fig, "data") + assert len(fig.data) > 0 + + +def test_plot_hex_map_invalid_location_type(): + """Test that invalid location type raises ValueError.""" + from policyengine.utils.maps import plot_hex_map + + with pytest.raises(ValueError, match="Invalid location_type"): + plot_hex_map({"area": 1.0}, "invalid_type") From 9972809cdd8cf1823d354d5ac49fa48d250164f2 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 11 Dec 2025 23:54:47 +0400 Subject: [PATCH 5/6] chore: Lint --- .vscode/settings.json | 5 +++++ tests/utils/test_maps.py | 10 ++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..87cdebf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda", + "python-envs.pythonProjects": [] +} \ No newline at end of file diff --git a/tests/utils/test_maps.py b/tests/utils/test_maps.py index e44c0c4..aef5915 100644 --- a/tests/utils/test_maps.py +++ b/tests/utils/test_maps.py @@ -29,7 +29,10 @@ def test_get_location_options_table_local_authorities(): def test_plot_hex_map_local_authorities(): """Test plotting hex map for local authorities.""" - from policyengine.utils.maps import get_location_options_table, plot_hex_map + from policyengine.utils.maps import ( + get_location_options_table, + plot_hex_map, + ) # Get local authority names df = get_location_options_table("local_authorities") @@ -47,7 +50,10 @@ def test_plot_hex_map_local_authorities(): def test_plot_hex_map_parliamentary_constituencies(): """Test plotting hex map for parliamentary constituencies.""" - from policyengine.utils.maps import get_location_options_table, plot_hex_map + from policyengine.utils.maps import ( + get_location_options_table, + plot_hex_map, + ) # Get constituency names df = get_location_options_table("parliamentary_constituencies") From 64cdaf96a0d1a4045a27ca0cdb4c0278d24bb431 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Fri, 12 Dec 2025 12:41:59 +0400 Subject: [PATCH 6/6] test: Fix tests --- .../macro/comparison/calculate_economy_comparison.py | 12 ++++++------ policyengine/simulation.py | 2 +- tests/country/test_uk.py | 12 +++++------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py index e2edb04..05e0b14 100644 --- a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py +++ b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py @@ -802,21 +802,21 @@ def uk_local_area_breakdown( baseline_hnet = baseline.household_net_income reform_hnet = reform.household_net_income - local_area_weights_path = download( + local_area_weights_local_path = download( gcs_bucket="policyengine-uk-data-private", - filepath="local_authority_weights.h5", + gcs_key="local_authority_weights.h5", ) - with h5py.File(local_area_weights_path, "r") as f: + with h5py.File(local_area_weights_local_path, "r") as f: weights = f["2025"][ ... ] # {2025: array(n_local_areas, n_households) where cell i, j is the weight of household record j in local area i} - local_area_names_path = download( + local_area_names_local_path = download( gcs_bucket="policyengine-uk-data-private", - filepath="local_authorities_2021.csv", + gcs_key="local_authorities_2021.csv", ) local_area_names = pd.read_csv( - str(local_area_names_path) + local_area_names_local_path ) # columns code (local area code), name (local area name), x, y (geographic position) for i in range(len(local_area_names)): diff --git a/policyengine/simulation.py b/policyengine/simulation.py index 909bc71..9cd68c8 100644 --- a/policyengine/simulation.py +++ b/policyengine/simulation.py @@ -356,7 +356,7 @@ def _apply_region_to_simulation( ) with h5py.File(weights_local_path, "r") as f: - weights = f[str(self.time_period)][...] + weights = f[str(time_period)][...] simulation.set_input( "household_weight", diff --git a/tests/country/test_uk.py b/tests/country/test_uk.py index 8fc2e9f..24ad02c 100644 --- a/tests/country/test_uk.py +++ b/tests/country/test_uk.py @@ -148,11 +148,9 @@ def test_uk_simulation_invalid_local_authority_raises_error(): """Test that invalid local authority raises ValueError.""" from policyengine import Simulation - sim = Simulation( - scope="macro", - country="uk", - region="local_authority/InvalidLocalAuthority123", - ) - with pytest.raises(ValueError, match="Local authority .* not found"): - sim.calculate_single_economy() + Simulation( + scope="macro", + country="uk", + region="local_authority/InvalidLocalAuthority123", + )