diff --git a/frontend/app/(shell)/page.tsx b/frontend/app/(shell)/page.tsx index 16a1d46..32ba1a2 100644 --- a/frontend/app/(shell)/page.tsx +++ b/frontend/app/(shell)/page.tsx @@ -3,13 +3,15 @@ import { useState } from 'react'; import HouseholdCalculator from '@/components/HouseholdCalculator'; import PolicyOverview from '@/components/PolicyOverview'; +import AggregateImpact from '@/components/AggregateImpact'; export default function Home() { - const [activeTab, setActiveTab] = useState<'policy' | 'calculator'>('policy'); + const [activeTab, setActiveTab] = useState<'policy' | 'calculator' | 'statewide'>('policy'); const TAB_CONFIG = [ { id: 'policy' as const, label: 'How it works' }, { id: 'calculator' as const, label: 'Household calculator' }, + { id: 'statewide' as const, label: 'Statewide impact' }, ]; return ( @@ -28,7 +30,7 @@ export default function Home() {
{/* Tabs */} -
+
{TAB_CONFIG.map((tab) => (
diff --git a/frontend/components/AggregateImpact.tsx b/frontend/components/AggregateImpact.tsx new file mode 100644 index 0000000..b277a24 --- /dev/null +++ b/frontend/components/AggregateImpact.tsx @@ -0,0 +1,484 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + ReferenceLine, + Cell, +} from 'recharts'; +import ChartWatermark from './ChartWatermark'; + +// App-v2 color tokens +const COLORS = { + gainMore5: '#285E61', // primary-700 + gainLess5: '#31979599', // primary-500 @ 60% + noChange: '#E2E8F0', // gray-200 + loseLess5: '#9CA3AF', // gray-400 + loseMore5: '#4B5563', // gray-600 + positive: '#319795', // primary-500 + negative: '#4B5563', // gray-600 +}; + +// Shared chart margins +const CHART_MARGIN = { top: 20, right: 20, bottom: 30, left: 60 }; + +// Shared axis tick style +const TICK_STYLE = { fontFamily: 'Inter, sans-serif', fontSize: 12 }; + +interface AggregateData { + budget: { + budgetary_impact: number; + households: number; + kicker_rate: number; + }; + decile: { + average: Record; + relative: Record; + }; + intra_decile: { + all: Record; + deciles: Record; + }; + total_cost: number; + beneficiaries: number; + avg_benefit: number; + winners: number; + losers: number; + winners_rate: number; + losers_rate: number; + poverty: { + poverty: { + all: { baseline: number; reform: number }; + child: { baseline: number; reform: number }; + }; + deep_poverty: { + all: { baseline: number; reform: number }; + child: { baseline: number; reform: number }; + }; + }; + by_income_bracket: { + bracket: string; + beneficiaries: number; + total_cost: number; + avg_benefit: number; + }[]; +} + +// Custom tooltip component +function CustomTooltip({ active, payload, label, formatter }: { + active?: boolean; + payload?: { name: string; value: number; color?: string }[]; + label?: string; + formatter?: (value: number, name: string) => string; +}) { + if (!active || !payload?.length) return null; + return ( +
+ {label &&

{label}

} + {payload.map((entry, i) => ( +

+ {entry.name}: {formatter ? formatter(entry.value, entry.name) : entry.value} +

+ ))} +
+ ); +} + +export default function AggregateImpact() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeSection, setActiveSection] = useState<'fiscal' | 'distributional' | 'winners' | 'poverty'>('fiscal'); + const [distMode, setDistMode] = useState<'relative' | 'absolute'>('relative'); + + useEffect(() => { + async function loadData() { + try { + const basePath = '/us/oregon-kicker-refund'; + const res = await fetch(`${basePath}/data/aggregate_impact.json`); + if (!res.ok) { + throw new Error(`Failed to load data: ${res.status}`); + } + const json = await res.json(); + setData(json); + } catch (err) { + console.error('Error loading data:', err); + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + } + loadData(); + }, []); + + if (loading) { + return ( +
+
+
+

Loading statewide impact data...

+
+
+ ); + } + + if (error || !data) { + return ( +
+

Statewide impact data not available

+

{error}

+

+ Precomputed data has not been generated yet. Run: python scripts/generate_impacts.py +

+
+ ); + } + + const formatCurrency = (value: number) => + `$${Math.abs(value).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; + const formatCurrencyWithSign = (value: number) => { + const formatted = `$${Math.abs(value).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; + return value >= 0 ? `+${formatted}` : `-${formatted}`; + }; + const formatBillions = (value: number) => { + const abs = Math.abs(value); + const sign = value >= 0 ? '+' : '-'; + if (abs >= 1e12) return `${sign}$${(abs / 1e12).toFixed(1)}T`; + if (abs >= 1e9) return `${sign}$${(abs / 1e9).toFixed(1)}B`; + if (abs >= 1e6) return `${sign}$${(abs / 1e6).toFixed(1)}M`; + return formatCurrencyWithSign(value); + }; + + // Section tabs + const sections = [ + { key: 'fiscal' as const, label: 'Budgetary impact' }, + { key: 'distributional' as const, label: 'Distributional impact' }, + { key: 'winners' as const, label: 'Winners & losers' }, + { key: 'poverty' as const, label: 'Poverty impact' }, + ]; + + return ( +
+

Statewide impact analysis

+ + {/* Sub-navigation */} +
+ {sections.map((s) => ( + + ))} +
+ + {/* ===== FISCAL IMPACT ===== */} + {activeSection === 'fiscal' && ( +
+ {/* Budget headline */} +
+

Budgetary impact (2025)

+

+ {formatBillions(data.budget.budgetary_impact)} +

+
+ + {/* Income bracket table */} +
+

Impact by income bracket

+
+ + + + + + + + + + + {data.by_income_bracket.map((bracket, index) => ( + + + + + + + ))} + +
Income bracketAffected householdsTotal impactAverage impact
{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) => ( + + ))} +
+
+

+ {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) => ( +
+
+ {c.label} +
+ ))} +
+
+
+
+ ); + })()} + + {/* ===== 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" }