From ef826fae8ab4f2852c36325fefb9fdf632731b87 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 8 Apr 2026 08:28:32 -0400 Subject: [PATCH] Add district outcome percentages to 0.x comparisons --- changelog_entry.yaml | 5 ++ .../calculate_economy_comparison.py | 49 +++++++++++++- .../test_us_congressional_districts.py | 66 +++++++++++++++++++ tests/fixtures/simulation.py | 7 ++ 4 files changed, 124 insertions(+), 3 deletions(-) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index e69de29b..af925064 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -0,0 +1,5 @@ +- bump: minor + changes: + added: + - Winner, loser, and no-change percentages for `congressional_district_impact` + in legacy 0.x economy comparisons diff --git a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py index 754f987c..1e2c627d 100644 --- a/policyengine/outputs/macro/comparison/calculate_economy_comparison.py +++ b/policyengine/outputs/macro/comparison/calculate_economy_comparison.py @@ -854,6 +854,9 @@ class USCongressionalDistrictImpact(BaseModel): district: str # e.g., "GA-05" average_household_income_change: float relative_household_income_change: float + winner_percentage: float + loser_percentage: float + no_change_percentage: float class USCongressionalDistrictBreakdownWithValues(BaseModel): @@ -906,9 +909,22 @@ def us_congressional_district_breakdown( district_name = geoid_to_district_name(geoid) # Extract household data for this district - weights = [baseline.household_weight[i] for i in indices] - baseline_incomes = [baseline.household_net_income[i] for i in indices] - reform_incomes = [reform.household_net_income[i] for i in indices] + weights = np.array([baseline.household_weight[i] for i in indices]) + baseline_incomes = np.array( + [baseline.household_net_income[i] for i in indices] + ) + reform_incomes = np.array( + [reform.household_net_income[i] for i in indices] + ) + household_count_people = getattr( + baseline, "household_count_people", None + ) + if household_count_people is None: + people_weights = weights + else: + people_weights = weights * np.array( + [household_count_people[i] for i in indices] + ) baseline_income = MicroSeries(baseline_incomes, weights=weights) reform_income = MicroSeries(reform_incomes, weights=weights) @@ -925,6 +941,30 @@ def us_congressional_district_breakdown( relative_household_income_change = ( reform_income.sum() / baseline_income.sum() - 1 ) + income_change = (reform_incomes - baseline_incomes) / np.maximum( + baseline_incomes, 1.0 + ) + total_people = float(np.sum(people_weights)) + + if total_people == 0: + winner_percentage = 0.0 + loser_percentage = 0.0 + no_change_percentage = 1.0 + else: + winner_percentage = float( + np.sum(people_weights[income_change > 1e-3]) / total_people + ) + loser_percentage = float( + np.sum(people_weights[income_change <= -1e-3]) / total_people + ) + no_change_percentage = float( + np.sum( + people_weights[ + (income_change > -1e-3) & (income_change <= 1e-3) + ] + ) + / total_people + ) districts.append( USCongressionalDistrictImpact( @@ -935,6 +975,9 @@ def us_congressional_district_breakdown( relative_household_income_change=float( relative_household_income_change ), + winner_percentage=winner_percentage, + loser_percentage=loser_percentage, + no_change_percentage=no_change_percentage, ) ) diff --git a/tests/country/test_us_congressional_districts.py b/tests/country/test_us_congressional_districts.py index 76937485..c1e38f01 100644 --- a/tests/country/test_us_congressional_districts.py +++ b/tests/country/test_us_congressional_districts.py @@ -185,11 +185,13 @@ def test__given_income_increase__then_returns_positive_changes(self): baseline = create_mock_single_economy( household_net_income=[50000.0, 60000.0, 70000.0], household_weight=[1000.0, 1000.0, 1000.0], + household_count_people=[2, 2, 2], congressional_district_geoid=[1305, 1305, 1305], ) reform = create_mock_single_economy( household_net_income=[51000.0, 61000.0, 71000.0], household_weight=[1000.0, 1000.0, 1000.0], + household_count_people=[2, 2, 2], congressional_district_geoid=[1305, 1305, 1305], ) country_id = "us" @@ -211,11 +213,13 @@ def test__given_income_decrease__then_returns_negative_changes(self): baseline = create_mock_single_economy( household_net_income=[50000.0, 60000.0, 70000.0], household_weight=[1000.0, 1000.0, 1000.0], + household_count_people=[2, 2, 2], congressional_district_geoid=[1305, 1305, 1305], ) reform = create_mock_single_economy( household_net_income=[49000.0, 59000.0, 69000.0], household_weight=[1000.0, 1000.0, 1000.0], + household_count_people=[2, 2, 2], congressional_district_geoid=[1305, 1305, 1305], ) country_id = "us" @@ -239,11 +243,13 @@ def test__given_weighted_households__then_calculates_weighted_averages( baseline = create_mock_single_economy( household_net_income=[50000.0, 100000.0], household_weight=[3000.0, 1000.0], # First household has 3x weight + household_count_people=[2, 2], congressional_district_geoid=[1305, 1305], ) reform = create_mock_single_economy( household_net_income=[51000.0, 101000.0], household_weight=[3000.0, 1000.0], + household_count_people=[2, 2], congressional_district_geoid=[1305, 1305], ) country_id = "us" @@ -279,9 +285,69 @@ def test__given_district_impact__then_has_required_fields( assert hasattr(district, "district") assert hasattr(district, "average_household_income_change") assert hasattr(district, "relative_household_income_change") + assert hasattr(district, "winner_percentage") + assert hasattr(district, "loser_percentage") + assert hasattr(district, "no_change_percentage") assert isinstance(district.district, str) assert isinstance(district.average_household_income_change, float) assert isinstance(district.relative_household_income_change, float) + assert isinstance(district.winner_percentage, float) + assert isinstance(district.loser_percentage, float) + assert isinstance(district.no_change_percentage, float) + + def test__given_mixed_outcomes__then_returns_people_weighted_percentages( + self, + ): + # Given: one winner, one loser, one unchanged household with different + # people counts. Outcome shares should be people-weighted. + baseline = create_mock_single_economy( + household_net_income=[1000.0, 1000.0, 1000.0], + household_weight=[1.0, 1.0, 1.0], + household_count_people=[1, 2, 3], + congressional_district_geoid=[1305, 1305, 1305], + ) + reform = create_mock_single_economy( + household_net_income=[1002.0, 999.0, 1000.0], + household_weight=[1.0, 1.0, 1.0], + household_count_people=[1, 2, 3], + congressional_district_geoid=[1305, 1305, 1305], + ) + + # When + result = us_congressional_district_breakdown(baseline, reform, "us") + + # Then + district = result.districts[0] + assert district.winner_percentage == pytest.approx(1 / 6) + assert district.loser_percentage == pytest.approx(2 / 6) + assert district.no_change_percentage == pytest.approx(3 / 6) + + def test__given_missing_household_count_people__then_falls_back_to_household_weights( + self, + ): + # Given: people counts are unavailable, so outcome shares should fall + # back to household weights. + baseline = create_mock_single_economy( + household_net_income=[1000.0, 1000.0], + household_weight=[3.0, 1.0], + household_count_people=None, + congressional_district_geoid=[1305, 1305], + ) + reform = create_mock_single_economy( + household_net_income=[1002.0, 998.0], + household_weight=[3.0, 1.0], + household_count_people=None, + congressional_district_geoid=[1305, 1305], + ) + + # When + result = us_congressional_district_breakdown(baseline, reform, "us") + + # Then + district = result.districts[0] + assert district.winner_percentage == pytest.approx(0.75) + assert district.loser_percentage == pytest.approx(0.25) + assert district.no_change_percentage == pytest.approx(0.0) class TestCongressionalDistrictGeoidExtraction: diff --git a/tests/fixtures/simulation.py b/tests/fixtures/simulation.py index a0f368fc..bb0bf44b 100644 --- a/tests/fixtures/simulation.py +++ b/tests/fixtures/simulation.py @@ -91,6 +91,7 @@ def mock_simulation_with_cliff_vars(): def create_mock_single_economy( household_net_income: list[float], household_weight: list[float], + household_count_people: list[int] | None = None, congressional_district_geoid: list[int] | None = None, ): """Create a mock SingleEconomy with specified household data. @@ -98,6 +99,7 @@ def create_mock_single_economy( Args: household_net_income: List of household net incomes household_weight: List of household weights + household_count_people: List of people per household or None congressional_district_geoid: List of district geoids (SSDD format) or None Returns: @@ -106,6 +108,7 @@ def create_mock_single_economy( mock_economy = Mock() mock_economy.household_net_income = household_net_income mock_economy.household_weight = household_weight + mock_economy.household_count_people = household_count_people mock_economy.congressional_district_geoid = congressional_district_geoid return mock_economy @@ -128,6 +131,7 @@ def mock_single_economy_with_ga_districts(): 100000.0, ], household_weight=[1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0], + household_count_people=[2, 2, 2, 2, 2, 2], congressional_district_geoid=[1305, 1305, 1305, 1306, 1306, 1306], ) @@ -163,6 +167,7 @@ def mock_single_economy_with_multi_state_districts(): 1000.0, 1000.0, ], + household_count_people=[2, 2, 2, 2, 2, 2, 2, 2], congressional_district_geoid=[ 1305, 1305, @@ -182,6 +187,7 @@ def mock_single_economy_without_districts(): return create_mock_single_economy( household_net_income=[50000.0, 60000.0, 70000.0], household_weight=[1000.0, 1000.0, 1000.0], + household_count_people=[2, 2, 2], congressional_district_geoid=[0, 0, 0], ) @@ -192,5 +198,6 @@ def mock_single_economy_with_null_districts(): return create_mock_single_economy( household_net_income=[50000.0, 60000.0, 70000.0], household_weight=[1000.0, 1000.0, 1000.0], + household_count_people=[2, 2, 2], congressional_district_geoid=None, )