+
Statewide impact analysis
+
+ {/* Sub-navigation */}
+
+ {sections.map((s) => (
+ setActiveSection(s.key)}
+ className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
+ activeSection === s.key
+ ? 'bg-primary-500 text-white'
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
+ }`}
+ >
+ {s.label}
+
+ ))}
+
+
+ {/* ===== FISCAL IMPACT ===== */}
+ {activeSection === 'fiscal' && (
+
+ {/* Budget headline */}
+
+
Budgetary impact (2025)
+
+ {formatBillions(data.budget.budgetary_impact)}
+
+
+
+ {/* Income bracket table */}
+
+
Impact by income bracket
+
+
+
+
+ Income bracket
+ Affected households
+ Total impact
+ Average impact
+
+
+
+ {data.by_income_bracket.map((bracket, index) => (
+
+ {bracket.bracket}
+ {Math.round(bracket.beneficiaries).toLocaleString()}
+ = 0 ? COLORS.positive : COLORS.negative }}>
+ {formatBillions(bracket.total_cost)}
+
+ = 0 ? COLORS.positive : COLORS.negative }}>
+ {formatCurrencyWithSign(bracket.avg_benefit)}
+
+
+ ))}
+
+
+
+
+
+ )}
+
+ {/* ===== DISTRIBUTIONAL IMPACT ===== */}
+ {activeSection === 'distributional' && (() => {
+ const isRelative = distMode === 'relative';
+ const rawValues = isRelative
+ ? Object.values(data.decile.relative).map(v => v * 100)
+ : Object.values(data.decile.average);
+ const maxAbs = Math.max(...rawValues.map(Math.abs));
+ const niceStep = (() => {
+ const rough = maxAbs / 3;
+ const mag = Math.pow(10, Math.floor(Math.log10(rough)));
+ const residual = rough / mag;
+ if (residual <= 1) return mag;
+ if (residual <= 2) return 2 * mag;
+ if (residual <= 5) return 5 * mag;
+ return 10 * mag;
+ })();
+ const niceMax = Math.ceil(maxAbs / niceStep) * niceStep;
+ const symmetricDomain = [-niceMax, niceMax];
+ const niceTicks = Array.from(
+ { length: Math.round(2 * niceMax / niceStep) + 1 },
+ (_, i) => -niceMax + i * niceStep,
+ );
+ const chartData = isRelative
+ ? Object.entries(data.decile.relative).map(([k, v]) => ({ decile: k, value: v * 100 }))
+ : Object.entries(data.decile.average).map(([k, v]) => ({ decile: k, value: v }));
+
+ return (
+
+
+
Impact by income decile
+
+ {(['relative', 'absolute'] as const).map((mode) => (
+ setDistMode(mode)}
+ className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
+ distMode === mode
+ ? 'bg-primary-500 text-white'
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
+ }`}
+ >
+ {mode === 'relative' ? 'Relative' : 'Absolute'}
+
+ ))}
+
+
+
+ {isRelative
+ ? 'Change in household net income as a percentage of baseline income, by decile.'
+ : 'Average change in household net income in dollars, by decile.'}
+
+
+
+
+
+
+ `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`
+ : formatCurrencyWithSign}
+ tick={TICK_STYLE}
+ stroke="#A0AEC0"
+ width={isRelative ? 60 : 80}
+ allowDecimals={false}
+ />
+ `${v >= 0 ? '+' : ''}${v.toFixed(2)}%`
+ : (v) => formatCurrencyWithSign(v)} />}
+ />
+
+
+ {rawValues.map((v, i) => (
+ | = 0 ? COLORS.positive : COLORS.negative} />
+ ))}
+ |
+
+
+
+
+
+ );
+ })()}
+
+ {/* ===== WINNERS & LOSERS ===== */}
+ {activeSection === 'winners' && (() => {
+ const intra = data.intra_decile;
+ const categories = [
+ { key: 'gain_more_than_5pct', label: 'Gain more than 5%', color: COLORS.gainMore5 },
+ { key: 'gain_less_than_5pct', label: 'Gain less than 5%', color: COLORS.gainLess5 },
+ { key: 'no_change', label: 'No change', color: COLORS.noChange },
+ { key: 'lose_less_than_5pct', label: 'Lose less than 5%', color: COLORS.loseLess5 },
+ { key: 'lose_more_than_5pct', label: 'Lose more than 5%', color: COLORS.loseMore5 },
+ ] as const;
+
+ const stackedData = [
+ {
+ label: 'All',
+ ...Object.fromEntries(categories.map(c => [c.key, (intra.all[c.key] * 100)])),
+ },
+ ...Array.from({ length: 10 }, (_, i) => {
+ const d = 10 - i;
+ return {
+ label: `${d}`,
+ ...Object.fromEntries(categories.map(c => [c.key, (intra.deciles[c.key][d - 1] * 100)])),
+ };
+ }),
+ ];
+
+ return (
+
+ {/* Headline */}
+
+
+
Winners
+
{data.winners_rate.toFixed(1)}%
+
+
+
No change
+
+ {(100 - data.winners_rate - data.losers_rate).toFixed(1)}%
+
+
+
+
Losers
+
{data.losers_rate.toFixed(1)}%
+
+
+
+ {/* Stacked bar chart by decile */}
+
+
Winners & losers by income decile
+
+
+
+
+ `${(v * 100).toFixed(0)}%`} tick={TICK_STYLE} stroke="#A0AEC0" />
+
+ `${v.toFixed(1)}%`} />} />
+ {categories.map((c) => (
+
+ ))}
+
+
+
+ {/* Custom legend */}
+
+ {categories.map((c) => (
+
+ ))}
+
+
+
+
+ );
+ })()}
+
+ {/* ===== POVERTY IMPACT ===== */}
+ {activeSection === 'poverty' && (() => {
+ const pov = data.poverty;
+ const povertyMetrics = [
+ {
+ label: 'Overall poverty',
+ baseline: pov.poverty.all.baseline,
+ reform: pov.poverty.all.reform,
+ },
+ {
+ label: 'Child poverty',
+ baseline: pov.poverty.child.baseline,
+ reform: pov.poverty.child.reform,
+ },
+ {
+ label: 'Deep poverty',
+ baseline: pov.deep_poverty.all.baseline,
+ reform: pov.deep_poverty.all.reform,
+ },
+ {
+ label: 'Deep child poverty',
+ baseline: pov.deep_poverty.child.baseline,
+ reform: pov.deep_poverty.child.reform,
+ },
+ ];
+
+ const chartData = povertyMetrics.map((m) => {
+ const pctChange = m.baseline !== 0 ? ((m.reform - m.baseline) / m.baseline) * 100 : 0;
+ return { ...m, pctChange };
+ });
+
+ const pctValues = chartData.map(d => d.pctChange);
+ const minVal = Math.min(0, ...pctValues);
+ const maxVal = Math.max(0, ...pctValues);
+ const maxAbs = Math.max(Math.abs(minVal), Math.abs(maxVal), 0.1);
+ const niceStep = (() => {
+ const rough = maxAbs / 3;
+ if (rough < 0.1) return 0.1;
+ const mag = Math.pow(10, Math.floor(Math.log10(rough)));
+ const residual = rough / mag;
+ if (residual <= 1) return mag;
+ if (residual <= 2) return 2 * mag;
+ if (residual <= 5) return 5 * mag;
+ return 10 * mag;
+ })();
+ const niceMin = Math.floor(minVal / niceStep) * niceStep;
+ const niceMax = Math.ceil(maxVal / niceStep) * niceStep;
+ const niceTicks = Array.from(
+ { length: Math.round((niceMax - niceMin) / niceStep) + 1 },
+ (_, i) => niceMin + i * niceStep,
+ );
+
+ return (
+
+
+
Change in poverty rates (%)
+
+
+
+
+
+ `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`} tick={TICK_STYLE} stroke="#A0AEC0" width={70} allowDecimals={false} />
+ `${v >= 0 ? '+' : ''}${v.toFixed(2)}%`} />} />
+
+
+ {chartData.map((m, i) => (
+ |
+ ))}
+
+
+
+
+
+
+
+ );
+ })()}
+
+
+ These estimates are static: they do not capture behavioral responses such as changes in labor supply, tax avoidance, or migration.
+
+
+ );
+}
diff --git a/frontend/components/ChartWatermark.tsx b/frontend/components/ChartWatermark.tsx
index ffcb3fb..711f84d 100644
--- a/frontend/components/ChartWatermark.tsx
+++ b/frontend/components/ChartWatermark.tsx
@@ -1,6 +1,6 @@
'use client';
-const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
+const basePath = '/us/oregon-kicker-refund';
/**
* PolicyEngine logo watermark for Recharts charts.
diff --git a/frontend/public/data/aggregate_impact.json b/frontend/public/data/aggregate_impact.json
new file mode 100644
index 0000000..b95cdd9
--- /dev/null
+++ b/frontend/public/data/aggregate_impact.json
@@ -0,0 +1,177 @@
+{
+ "budget": {
+ "budgetary_impact": 1016779514.4976325,
+ "households": 1408225.024815682,
+ "kicker_rate": 0.09863
+ },
+ "decile": {
+ "average": {
+ "1": 10.915402711882539,
+ "2": 40.89120881858048,
+ "3": 86.79357828666747,
+ "4": 127.72148924890388,
+ "5": 213.09444960804655,
+ "6": 322.95343157234873,
+ "7": 461.1295026158431,
+ "8": 695.7635899435102,
+ "9": 1076.5006955576162,
+ "10": 6596.931329168052
+ },
+ "relative": {
+ "1": 0.0013640959516670386,
+ "2": 0.0018844166306481798,
+ "3": 0.002679547721636616,
+ "4": 0.003001196621309371,
+ "5": 0.004046818962794093,
+ "6": 0.005115732454542427,
+ "7": 0.006077379043979818,
+ "8": 0.0072214808621775045,
+ "9": 0.007868340116523913,
+ "10": 0.009430130280389961
+ }
+ },
+ "intra_decile": {
+ "all": {
+ "lose_more_than_5pct": 0.0,
+ "lose_less_than_5pct": 1.380710626278108e-06,
+ "no_change": 0.2559967370075956,
+ "gain_less_than_5pct": 0.7429449879993901,
+ "gain_more_than_5pct": 0.0010568942823878655
+ },
+ "deciles": {
+ "lose_more_than_5pct": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "lose_less_than_5pct": [
+ 0.0,
+ 6.120969638090305e-07,
+ 4.294799893467401e-06,
+ 0.0,
+ 8.082192313892695e-07,
+ 0.0,
+ 0.0,
+ 0.0,
+ 5.312551572842944e-06,
+ 2.7794386012724365e-06
+ ],
+ "no_change": [
+ 0.8064258634117094,
+ 0.5805301971178748,
+ 0.38862738042356393,
+ 0.3473577864795836,
+ 0.21448197303778527,
+ 0.1511344730219827,
+ 0.058903874036100506,
+ 0.0024422713015820174,
+ 8.087980520021915e-06,
+ 0.010055463265254956
+ ],
+ "gain_less_than_5pct": [
+ 0.1897292778598884,
+ 0.41769442820314534,
+ 0.6113682784642014,
+ 0.6477122192503771,
+ 0.7855172187429833,
+ 0.8488655269780173,
+ 0.9410961259638995,
+ 0.997557728698418,
+ 0.9999689746785063,
+ 0.9899401011544648
+ ],
+ "gain_more_than_5pct": [
+ 0.0038448587284022275,
+ 0.0017747625820160968,
+ 4.6312341317027926e-08,
+ 0.004929994270039221,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.7624789400855887e-05,
+ 1.6561416789368185e-06
+ ]
+ }
+ },
+ "total_cost": 1016779514.4976325,
+ "beneficiaries": 969324.5693850776,
+ "avg_benefit": 1051.9888916015625,
+ "winners": 966527.4570332502,
+ "losers": 1.6303008482791483,
+ "winners_rate": 68.63444691019859,
+ "losers_rate": 0.00011576991031618209,
+ "poverty": {
+ "poverty": {
+ "all": {
+ "baseline": 34.991946992359885,
+ "reform": 34.78913073286016
+ },
+ "child": {
+ "baseline": 23.4125919342041,
+ "reform": 23.157867431640625
+ }
+ },
+ "deep_poverty": {
+ "all": {
+ "baseline": 14.892613553142203,
+ "reform": 14.834656836037563
+ },
+ "child": {
+ "baseline": 6.47815465927124,
+ "reform": 6.470669746398926
+ }
+ }
+ },
+ "by_income_bracket": [
+ {
+ "bracket": "Under $50k",
+ "beneficiaries": 428067.53125,
+ "total_cost": 50029776.0,
+ "avg_benefit": 116.8735580444336
+ },
+ {
+ "bracket": "$50k-$100k",
+ "beneficiaries": 279156.5625,
+ "total_cost": 126807680.0,
+ "avg_benefit": 454.2528991699219
+ },
+ {
+ "bracket": "$100k-$200k",
+ "beneficiaries": 156941.375,
+ "total_cost": 148417440.0,
+ "avg_benefit": 945.6871337890625
+ },
+ {
+ "bracket": "$200k-$500k",
+ "beneficiaries": 74037.21875,
+ "total_cost": 170892864.0,
+ "avg_benefit": 2308.2021484375
+ },
+ {
+ "bracket": "$500k-$1M",
+ "beneficiaries": 8739.26953125,
+ "total_cost": 46434784.0,
+ "avg_benefit": 5313.3486328125
+ },
+ {
+ "bracket": "$1M-$2M",
+ "beneficiaries": 2134.334228515625,
+ "total_cost": 24543972.0,
+ "avg_benefit": 11499.591796875
+ },
+ {
+ "bracket": "Over $2M",
+ "beneficiaries": 13746.0234375,
+ "total_cost": 449157536.0,
+ "avg_benefit": 32675.453125
+ }
+ ]
+}
\ No newline at end of file
diff --git a/frontend/public/data/distributional_impact.csv b/frontend/public/data/distributional_impact.csv
new file mode 100644
index 0000000..fb9d715
--- /dev/null
+++ b/frontend/public/data/distributional_impact.csv
@@ -0,0 +1,11 @@
+decile,total_change_billions,avg_change_per_hh,households_millions
+1,0.00232,12.4,0.187
+2,0.010797,54.78,0.197
+3,0.015994,92.14,0.174
+4,0.021655,129.93,0.167
+5,0.031404,223.16,0.141
+6,0.045777,356.03,0.129
+7,0.049882,490.35,0.102
+8,0.067393,746.7,0.09
+9,0.101749,1154.96,0.088
+10,0.744553,7101.12,0.105
diff --git a/frontend/public/data/metrics.csv b/frontend/public/data/metrics.csv
new file mode 100644
index 0000000..b889b65
--- /dev/null
+++ b/frontend/public/data/metrics.csv
@@ -0,0 +1,6 @@
+metric,value
+total_cost_billions,1.1
+beneficiaries_millions,0.99
+average_credit,1119.7
+kicker_rate_percent,9.863
+average_gain_winners,1119.7
diff --git a/frontend/public/data/poverty_impact.csv b/frontend/public/data/poverty_impact.csv
new file mode 100644
index 0000000..fb1d469
--- /dev/null
+++ b/frontend/public/data/poverty_impact.csv
@@ -0,0 +1,4 @@
+metric,baseline,reformed,change_pp
+overall_poverty,35.24,35.08,-0.15
+child_poverty,24.17,24.01,-0.17
+deep_poverty,14.75,14.71,-0.04
diff --git a/frontend/public/data/winners_losers.csv b/frontend/public/data/winners_losers.csv
new file mode 100644
index 0000000..fba63d7
--- /dev/null
+++ b/frontend/public/data/winners_losers.csv
@@ -0,0 +1,4 @@
+category,percent,average_change
+winners,69.2,1119.7
+losers,0.0,0.0
+unchanged,30.8,0.0
diff --git a/scripts/generate_impacts.py b/scripts/generate_impacts.py
new file mode 100644
index 0000000..47fb208
--- /dev/null
+++ b/scripts/generate_impacts.py
@@ -0,0 +1,346 @@
+"""Aggregate impact calculations for Oregon Kicker using state microsimulation.
+
+Uses MicroSeries throughout where possible. MicroSeries.sum() is
+automatically weighted, and boolean masks preserve entity alignment.
+Only drops to numpy for operations MicroSeries can't do (groupby-like
+decile loops, child poverty age filtering).
+
+Based on the Keep Your Pay Act microsimulation pattern.
+"""
+
+import json
+import numpy as np
+from pathlib import Path
+from policyengine_us import Microsimulation
+from policyengine_core.reforms import Reform
+
+YEAR = 2024
+KICKER_RATE = 0.09863 # 9.863% for 2025 kicker (based on 2024 tax filing)
+DATASET = "hf://policyengine/policyengine-us-data/states/OR.h5"
+OUTPUT_DIR = Path(__file__).parent.parent / "frontend" / "public" / "data"
+
+# API v2 intra-decile bounds and labels
+_INTRA_BOUNDS = [-np.inf, -0.05, -1e-3, 1e-3, 0.05, np.inf]
+_INTRA_LABELS = [
+ "Lose more than 5%",
+ "Lose less than 5%",
+ "No change",
+ "Gain less than 5%",
+ "Gain more than 5%",
+]
+# Keys for JSON output (matching KYPA component expectations)
+_INTRA_KEYS = [
+ "lose_more_than_5pct",
+ "lose_less_than_5pct",
+ "no_change",
+ "gain_less_than_5pct",
+ "gain_more_than_5pct",
+]
+
+
+def create_kicker_reform():
+ """Create reform that sets the kicker rate to 9.863% (actual 2025 rate)."""
+ return Reform.from_dict({
+ "gov.states.or.tax.income.credits.kicker.percent": {
+ "2024-01-01.2100-12-31": KICKER_RATE
+ }
+ }, country_id="us")
+
+
+def _poverty_metrics(baseline_rate, reform_rate):
+ """Return rate change and percent change for a poverty metric."""
+ rate_change = reform_rate - baseline_rate
+ percent_change = (
+ rate_change / baseline_rate * 100
+ if baseline_rate > 0
+ else 0.0
+ )
+ return rate_change, percent_change
+
+
+def calculate_aggregate_impact():
+ """Run microsimulation and return aggregate impact data (KYPA format)."""
+ print(f"Setting up simulations for {YEAR}...")
+
+ kicker_reform = create_kicker_reform()
+
+ # Baseline: Current law (no kicker for 2024, rate = 0%)
+ sim_baseline = Microsimulation(dataset=DATASET)
+ # Reform: Kicker at 9.863% rate
+ sim_reform = Microsimulation(reform=kicker_reform, dataset=DATASET)
+
+ # Inject 2024 tax as prior-year tax (the kicker is based on this)
+ print("Calculating Oregon tax for 2024...")
+ tax_before_credits = sim_baseline.calculate("or_income_tax_before_credits", period=YEAR)
+ sim_baseline.set_input("or_tax_before_credits_in_prior_year", YEAR, tax_before_credits.values)
+ sim_reform.set_input("or_tax_before_credits_in_prior_year", YEAR, tax_before_credits.values)
+
+ print(f"Kicker rate: {KICKER_RATE * 100:.3f}%")
+
+ # ===== FISCAL IMPACT =====
+ print("Computing fiscal impact...")
+ baseline_net_income = sim_baseline.calculate(
+ "household_net_income", period=YEAR, map_to="household"
+ )
+ reform_net_income = sim_reform.calculate(
+ "household_net_income", period=YEAR, map_to="household"
+ )
+ income_change = reform_net_income - baseline_net_income
+
+ total_income_change = float(income_change.sum())
+
+ # Total households: (x * 0 + 1).sum() = sum(weights)
+ total_households = float((income_change * 0 + 1).sum())
+
+ # ===== WINNERS / LOSERS =====
+ print("Computing winners/losers...")
+ winners = float((income_change > 1).sum())
+ losers = float((income_change < -1).sum())
+ beneficiaries = float((income_change > 0).sum())
+
+ affected = abs(income_change) > 1
+ affected_count = float(affected.sum())
+
+ # Use numpy for correct weighted average
+ household_weight = sim_reform.calculate("household_weight", period=YEAR)
+ affected_mask = np.array(affected).astype(bool)
+ change_arr = np.array(income_change)
+ weight_arr = np.array(household_weight)
+
+ avg_benefit = (
+ float(np.average(
+ change_arr[affected_mask],
+ weights=weight_arr[affected_mask],
+ ))
+ if affected_count > 0
+ else 0.0
+ )
+
+ winners_rate = winners / total_households * 100
+ losers_rate = losers / total_households * 100
+
+ # ===== INCOME DECILE ANALYSIS =====
+ print("Computing distributional impact by decile...")
+ decile = sim_baseline.calculate(
+ "household_income_decile", period=YEAR, map_to="household"
+ )
+
+ decile_average = {}
+ decile_relative = {}
+ for d in range(1, 11):
+ dmask = decile == d
+ d_count = float(dmask.sum())
+ if d_count > 0:
+ d_change_sum = float(income_change[dmask].sum())
+ decile_average[str(d)] = d_change_sum / d_count
+ d_baseline_sum = float(baseline_net_income[dmask].sum())
+ decile_relative[str(d)] = (
+ d_change_sum / d_baseline_sum
+ if d_baseline_sum != 0
+ else 0.0
+ )
+ else:
+ decile_average[str(d)] = 0.0
+ decile_relative[str(d)] = 0.0
+
+ # Intra-decile requires person-weighted proportions — need numpy
+ print("Computing intra-decile distribution...")
+ people_per_hh = sim_baseline.calculate(
+ "household_count_people", period=YEAR, map_to="household"
+ )
+ capped_baseline = np.maximum(np.array(baseline_net_income), 1)
+ rel_change_arr = np.array(income_change) / capped_baseline
+
+ decile_arr = np.array(decile)
+ people_weighted = np.array(people_per_hh) * weight_arr
+
+ intra_decile_deciles = {key: [] for key in _INTRA_KEYS}
+ for d in range(1, 11):
+ dmask = decile_arr == d
+ d_people = people_weighted[dmask]
+ d_total_people = d_people.sum()
+ d_rel = rel_change_arr[dmask]
+
+ for lower, upper, key in zip(
+ _INTRA_BOUNDS[:-1], _INTRA_BOUNDS[1:], _INTRA_KEYS
+ ):
+ in_group = (d_rel > lower) & (d_rel <= upper)
+ proportion = (
+ float(d_people[in_group].sum() / d_total_people)
+ if d_total_people > 0
+ else 0.0
+ )
+ intra_decile_deciles[key].append(proportion)
+
+ intra_decile_all = {
+ key: sum(intra_decile_deciles[key]) / 10
+ for key in _INTRA_KEYS
+ }
+
+ # ===== POVERTY IMPACT =====
+ print("Computing poverty impact...")
+ pov_bl = sim_baseline.calculate("in_poverty", period=YEAR, map_to="person")
+ pov_rf = sim_reform.calculate("in_poverty", period=YEAR, map_to="person")
+
+ poverty_baseline_rate = float(pov_bl.mean() * 100)
+ poverty_reform_rate = float(pov_rf.mean() * 100)
+ poverty_rate_change, poverty_percent_change = _poverty_metrics(
+ poverty_baseline_rate, poverty_reform_rate
+ )
+
+ # Child poverty needs age filtering — numpy required
+ age_arr = np.array(sim_baseline.calculate("age", period=YEAR))
+ is_child = age_arr < 18
+ pw_arr = np.array(sim_baseline.calculate("person_weight", period=YEAR))
+ child_w = pw_arr[is_child]
+ total_child_w = child_w.sum()
+
+ pov_bl_arr = np.array(pov_bl).astype(bool)
+ pov_rf_arr = np.array(pov_rf).astype(bool)
+
+ def _child_rate(arr):
+ return float(
+ (arr[is_child] * child_w).sum() / total_child_w * 100
+ ) if total_child_w > 0 else 0.0
+
+ child_poverty_baseline_rate = _child_rate(pov_bl_arr)
+ child_poverty_reform_rate = _child_rate(pov_rf_arr)
+ child_poverty_rate_change, child_poverty_percent_change = _poverty_metrics(
+ child_poverty_baseline_rate, child_poverty_reform_rate
+ )
+
+ # Deep poverty
+ deep_bl = sim_baseline.calculate("in_deep_poverty", period=YEAR, map_to="person")
+ deep_rf = sim_reform.calculate("in_deep_poverty", period=YEAR, map_to="person")
+ deep_poverty_baseline_rate = float(deep_bl.mean() * 100)
+ deep_poverty_reform_rate = float(deep_rf.mean() * 100)
+ deep_poverty_rate_change, deep_poverty_percent_change = _poverty_metrics(
+ deep_poverty_baseline_rate, deep_poverty_reform_rate
+ )
+
+ deep_bl_arr = np.array(deep_bl).astype(bool)
+ deep_rf_arr = np.array(deep_rf).astype(bool)
+ deep_child_poverty_baseline_rate = _child_rate(deep_bl_arr)
+ deep_child_poverty_reform_rate = _child_rate(deep_rf_arr)
+ deep_child_poverty_rate_change, deep_child_poverty_percent_change = _poverty_metrics(
+ deep_child_poverty_baseline_rate,
+ deep_child_poverty_reform_rate,
+ )
+
+ # ===== INCOME BRACKET BREAKDOWN =====
+ print("Computing income bracket breakdown...")
+ agi = sim_reform.calculate(
+ "adjusted_gross_income", period=YEAR, map_to="household"
+ )
+ agi_arr = np.array(agi)
+ affected_mask_brackets = np.abs(change_arr) > 1
+
+ income_brackets = [
+ (0, 50_000, "Under $50k"),
+ (50_000, 100_000, "$50k-$100k"),
+ (100_000, 200_000, "$100k-$200k"),
+ (200_000, 500_000, "$200k-$500k"),
+ (500_000, 1_000_000, "$500k-$1M"),
+ (1_000_000, 2_000_000, "$1M-$2M"),
+ (2_000_000, float("inf"), "Over $2M"),
+ ]
+
+ by_income_bracket = []
+ for min_inc, max_inc, label in income_brackets:
+ mask = (
+ (agi_arr >= min_inc)
+ & (agi_arr < max_inc)
+ & affected_mask_brackets
+ )
+ bracket_affected = float(weight_arr[mask].sum())
+ if bracket_affected > 0:
+ bracket_cost = float(
+ (change_arr[mask] * weight_arr[mask]).sum()
+ )
+ bracket_avg = float(
+ np.average(change_arr[mask], weights=weight_arr[mask])
+ )
+ else:
+ bracket_cost = 0.0
+ bracket_avg = 0.0
+ by_income_bracket.append({
+ "bracket": label,
+ "beneficiaries": bracket_affected,
+ "total_cost": bracket_cost,
+ "avg_benefit": bracket_avg,
+ })
+
+ # ===== COMPILE RESULTS (KYPA FORMAT) =====
+ return {
+ "budget": {
+ "budgetary_impact": total_income_change,
+ "households": total_households,
+ "kicker_rate": KICKER_RATE,
+ },
+ "decile": {
+ "average": decile_average,
+ "relative": decile_relative,
+ },
+ "intra_decile": {
+ "all": intra_decile_all,
+ "deciles": intra_decile_deciles,
+ },
+ "total_cost": total_income_change,
+ "beneficiaries": beneficiaries,
+ "avg_benefit": avg_benefit,
+ "winners": winners,
+ "losers": losers,
+ "winners_rate": winners_rate,
+ "losers_rate": losers_rate,
+ "poverty": {
+ "poverty": {
+ "all": {
+ "baseline": poverty_baseline_rate,
+ "reform": poverty_reform_rate,
+ },
+ "child": {
+ "baseline": child_poverty_baseline_rate,
+ "reform": child_poverty_reform_rate,
+ },
+ },
+ "deep_poverty": {
+ "all": {
+ "baseline": deep_poverty_baseline_rate,
+ "reform": deep_poverty_reform_rate,
+ },
+ "child": {
+ "baseline": deep_child_poverty_baseline_rate,
+ "reform": deep_child_poverty_reform_rate,
+ },
+ },
+ },
+ "by_income_bracket": by_income_bracket,
+ }
+
+
+def main():
+ print("=" * 60)
+ print("Oregon Kicker Credit Impact Analysis")
+ print("=" * 60)
+
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+ data = calculate_aggregate_impact()
+
+ # Save as single JSON file (like KYPA)
+ filepath = OUTPUT_DIR / "aggregate_impact.json"
+ with open(filepath, "w") as f:
+ json.dump(data, f, indent=2)
+ print(f"Saved: {filepath}")
+
+ print("\n" + "=" * 60)
+ print("Summary:")
+ print(f" Total cost: ${data['total_cost'] / 1e9:.2f}B")
+ print(f" Beneficiaries: {data['beneficiaries'] / 1e6:.2f}M")
+ print(f" Average credit: ${data['avg_benefit']:.2f}")
+ print(f" Kicker rate: {data['budget']['kicker_rate'] * 100:.3f}%")
+ print("=" * 60)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/vercel.json b/vercel.json
index a667db8..3bf2253 100644
--- a/vercel.json
+++ b/vercel.json
@@ -1,4 +1,7 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
- "framework": "nextjs"
+ "framework": "nextjs",
+ "installCommand": "cd frontend && npm install",
+ "buildCommand": "cd frontend && npm run build",
+ "outputDirectory": "frontend/.next"
}