Skip to content

Commit 3f95127

Browse files
anth-volkclaude
andcommitted
fix: Address review findings for economic impact consolidation
Fix DecileImpact.run() StopIteration bug, move inline imports to top-level, improve exception logging in compute_program_statistics, use model_construct in compute_decile_impacts, and add 11 tests for the new economic impact output modules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bc5dcff commit 3f95127

5 files changed

Lines changed: 220 additions & 15 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Shared compute functions for economic impact analysis: CountryConfig, BudgetSummaryItem, compute_program_statistics, compute_decile_impacts, and PolicyReformAnalysis

src/policyengine/outputs/decile_impact.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,15 @@ def run(self):
3434
"""Calculate impact for this specific decile."""
3535
# Get variable object to determine entity
3636
var_obj = next(
37-
v
38-
for v in self.baseline_simulation.tax_benefit_model_version.variables
39-
if v.name == self.income_variable
37+
(
38+
v
39+
for v in self.baseline_simulation.tax_benefit_model_version.variables
40+
if v.name == self.income_variable
41+
),
42+
None,
4043
)
44+
if var_obj is None:
45+
raise ValueError(f"Variable '{self.income_variable}' not found in model")
4146

4247
# Get target entity
4348
target_entity = self.entity or var_obj.entity
@@ -120,7 +125,7 @@ def compute_decile_impacts(
120125
"""
121126
results = []
122127
for decile in range(1, quantiles + 1):
123-
impact = DecileImpact(
128+
impact = DecileImpact.model_construct(
124129
baseline_simulation=baseline_simulation,
125130
reform_simulation=reform_simulation,
126131
income_variable=income_variable,

src/policyengine/outputs/program_statistics.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ def compute_program_statistics(
8383
)
8484
stats.run()
8585
results.append(stats)
86-
except (KeyError, ValueError):
87-
logger.warning("Skipping program %s: variable not found", prog_name)
86+
except (KeyError, ValueError) as exc:
87+
logger.warning("Skipping program %s: %s", prog_name, exc, exc_info=True)
8888
continue
8989

9090
df = pd.DataFrame(

src/policyengine/tax_benefit_models/us/analysis.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path
55
from typing import Any
66

7+
import numpy as np
78
import pandas as pd
89
from microdf import MicroDataFrame
910
from pydantic import BaseModel, Field
@@ -16,7 +17,12 @@
1617
from policyengine.outputs.inequality import calculate_us_inequality
1718
from policyengine.outputs.intra_decile_impact import compute_intra_decile_impacts
1819
from policyengine.outputs.policy_reform_analysis import PolicyReformAnalysis
19-
from policyengine.outputs.poverty import calculate_us_poverty_rates
20+
from policyengine.outputs.poverty import (
21+
calculate_us_poverty_by_age,
22+
calculate_us_poverty_by_gender,
23+
calculate_us_poverty_by_race,
24+
calculate_us_poverty_rates,
25+
)
2026
from policyengine.outputs.program_statistics import compute_program_statistics
2127

2228
from .datasets import PolicyEngineUSDataset, USYearData
@@ -219,8 +225,6 @@ def economic_impact_analysis(
219225
)
220226

221227
# Household counts — raw weight sums to avoid MicroSeries double-weighting
222-
import numpy as np
223-
224228
hh_weight_baseline = baseline_simulation.output_dataset.data.household[
225229
"household_weight"
226230
]
@@ -242,12 +246,6 @@ def economic_impact_analysis(
242246
reform_poverty = calculate_us_poverty_rates(reform_simulation)
243247

244248
# Poverty by demographics
245-
from policyengine.outputs.poverty import (
246-
calculate_us_poverty_by_age,
247-
calculate_us_poverty_by_gender,
248-
calculate_us_poverty_by_race,
249-
)
250-
251249
baseline_poverty_by_age = calculate_us_poverty_by_age(baseline_simulation)
252250
reform_poverty_by_age = calculate_us_poverty_by_age(reform_simulation)
253251
baseline_poverty_by_gender = calculate_us_poverty_by_gender(baseline_simulation)
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""Tests for the new economic impact output modules."""
2+
3+
from dataclasses import FrozenInstanceError
4+
from unittest.mock import MagicMock
5+
6+
import numpy as np
7+
import pandas as pd
8+
import pytest
9+
from microdf import MicroDataFrame
10+
11+
from policyengine.outputs.change_aggregate import ChangeAggregate, ChangeAggregateType
12+
from policyengine.outputs.country_config import UK_CONFIG, US_CONFIG
13+
from policyengine.outputs.decile_impact import DecileImpact, compute_decile_impacts
14+
15+
# ---------------------------------------------------------------------------
16+
# Helpers (same pattern as test_intra_decile_impact.py)
17+
# ---------------------------------------------------------------------------
18+
19+
20+
def _make_variable_mock(name: str, entity: str) -> MagicMock:
21+
"""Create a mock Variable with name and entity attributes."""
22+
var = MagicMock()
23+
var.name = name
24+
var.entity = entity
25+
return var
26+
27+
28+
def _make_sim(household_data: dict, variables: list | None = None) -> MagicMock:
29+
"""Create a mock Simulation with household-level data."""
30+
hh_df = MicroDataFrame(
31+
pd.DataFrame(household_data),
32+
weights="household_weight",
33+
)
34+
sim = MagicMock()
35+
sim.output_dataset.data.household = hh_df
36+
sim.id = "test-sim"
37+
if variables is not None:
38+
sim.tax_benefit_model_version.variables = variables
39+
return sim
40+
41+
42+
# ---------------------------------------------------------------------------
43+
# CountryConfig tests
44+
# ---------------------------------------------------------------------------
45+
46+
47+
def test_us_config_is_frozen():
48+
"""US_CONFIG should be immutable."""
49+
with pytest.raises(FrozenInstanceError):
50+
US_CONFIG.country_id = "uk"
51+
52+
53+
def test_uk_config_is_frozen():
54+
"""UK_CONFIG should be immutable."""
55+
with pytest.raises(FrozenInstanceError):
56+
UK_CONFIG.country_id = "us"
57+
58+
59+
def test_us_config_has_correct_country_id():
60+
assert US_CONFIG.country_id == "us"
61+
62+
63+
def test_uk_config_has_correct_country_id():
64+
assert UK_CONFIG.country_id == "uk"
65+
66+
67+
def test_us_config_programs():
68+
"""US_CONFIG should contain expected program keys."""
69+
expected = {
70+
"income_tax",
71+
"employee_payroll_tax",
72+
"snap",
73+
"tanf",
74+
"ssi",
75+
"social_security",
76+
}
77+
assert set(US_CONFIG.programs.keys()) == expected
78+
79+
80+
def test_uk_config_programs():
81+
"""UK_CONFIG should contain expected programme keys."""
82+
expected = {
83+
"income_tax",
84+
"national_insurance",
85+
"vat",
86+
"council_tax",
87+
"universal_credit",
88+
"child_benefit",
89+
"pension_credit",
90+
"income_support",
91+
"working_tax_credit",
92+
"child_tax_credit",
93+
}
94+
assert set(UK_CONFIG.programs.keys()) == expected
95+
96+
97+
def test_country_config_program_structure():
98+
"""Each program entry should have 'entity' and 'is_tax' keys."""
99+
for name, info in US_CONFIG.programs.items():
100+
assert "entity" in info, f"US program {name} missing 'entity'"
101+
assert "is_tax" in info, f"US program {name} missing 'is_tax'"
102+
for name, info in UK_CONFIG.programs.items():
103+
assert "entity" in info, f"UK programme {name} missing 'entity'"
104+
assert "is_tax" in info, f"UK programme {name} missing 'is_tax'"
105+
106+
107+
# ---------------------------------------------------------------------------
108+
# DecileImpact tests
109+
# ---------------------------------------------------------------------------
110+
111+
112+
def test_decile_impact_variable_not_found():
113+
"""DecileImpact.run() should raise ValueError for a nonexistent variable."""
114+
variables = [_make_variable_mock("household_net_income", "household")]
115+
sim = _make_sim(
116+
{"household_net_income": [50000.0], "household_weight": [1.0]},
117+
variables=variables,
118+
)
119+
120+
di = DecileImpact.model_construct(
121+
baseline_simulation=sim,
122+
reform_simulation=sim,
123+
income_variable="nonexistent_variable",
124+
entity="household",
125+
decile=1,
126+
)
127+
with pytest.raises(ValueError, match="not found in model"):
128+
di.run()
129+
130+
131+
def test_compute_decile_impacts_returns_10():
132+
"""compute_decile_impacts should return 10 DecileImpact objects by default."""
133+
n = 100
134+
incomes = np.linspace(10000, 100000, n)
135+
reform_incomes = incomes + 500
136+
variables = [_make_variable_mock("household_net_income", "household")]
137+
138+
baseline = _make_sim(
139+
{"household_net_income": incomes, "household_weight": np.ones(n)},
140+
variables=variables,
141+
)
142+
reform = _make_sim(
143+
{"household_net_income": reform_incomes, "household_weight": np.ones(n)},
144+
variables=variables,
145+
)
146+
147+
result = compute_decile_impacts(
148+
baseline, reform, income_variable="household_net_income", entity="household"
149+
)
150+
151+
assert len(result.outputs) == 10
152+
assert len(result.dataframe) == 10
153+
154+
# Each decile should have absolute_change ~500
155+
for di in result.outputs:
156+
assert abs(di.absolute_change - 500.0) < 1e-6
157+
158+
159+
def test_compute_decile_impacts_custom_quantiles():
160+
"""compute_decile_impacts with quantiles=5 should return 5 outputs."""
161+
n = 100
162+
incomes = np.linspace(10000, 100000, n)
163+
variables = [_make_variable_mock("household_net_income", "household")]
164+
165+
sim = _make_sim(
166+
{"household_net_income": incomes, "household_weight": np.ones(n)},
167+
variables=variables,
168+
)
169+
170+
result = compute_decile_impacts(
171+
sim,
172+
sim,
173+
income_variable="household_net_income",
174+
entity="household",
175+
quantiles=5,
176+
)
177+
178+
assert len(result.outputs) == 5
179+
180+
181+
# ---------------------------------------------------------------------------
182+
# ChangeAggregate error test
183+
# ---------------------------------------------------------------------------
184+
185+
186+
def test_change_aggregate_variable_not_found():
187+
"""ChangeAggregate should raise ValueError for a nonexistent variable."""
188+
variables = [_make_variable_mock("employment_income", "person")]
189+
sim = _make_sim(
190+
{"household_net_income": [50000.0], "household_weight": [1.0]},
191+
variables=variables,
192+
)
193+
194+
ca = ChangeAggregate.model_construct(
195+
baseline_simulation=sim,
196+
reform_simulation=sim,
197+
variable="nonexistent_variable",
198+
aggregate_type=ChangeAggregateType.COUNT,
199+
)
200+
with pytest.raises(ValueError, match="not found in model"):
201+
ca.run()

0 commit comments

Comments
 (0)