From 1ae6a558f14ae6a542af7e451b84e361ad7b578c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 17 May 2026 08:38:01 -0400 Subject: [PATCH] Focus dashboard on 2024 Census SPM comparison --- data/spm_gap_diagnostics.json | 16 +- frontend/app/layout.tsx | 4 +- frontend/app/page.tsx | 149 ++++++++------- frontend/components/Census2024Focus.tsx | 243 ++++++++++++++++++++++++ frontend/components/FederalCard.tsx | 2 +- frontend/components/GapDiagnostics.tsx | 7 +- frontend/components/Header.tsx | 2 +- frontend/lib/format.ts | 6 + poverty_dashboard/spm_diagnostics.py | 30 ++- tests/test_spm_diagnostics.py | 11 +- 10 files changed, 382 insertions(+), 88 deletions(-) create mode 100644 frontend/components/Census2024Focus.tsx diff --git a/data/spm_gap_diagnostics.json b/data/spm_gap_diagnostics.json index cddeeb7..6cb3a5a 100644 --- a/data/spm_gap_diagnostics.json +++ b/data/spm_gap_diagnostics.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-05-17T11:31:31.451350+00:00", + "generated_at": "2026-05-17T12:33:34.292145+00:00", "title": "Why is PolicyEngine higher than Census SPM?", "summary": [ "PolicyEngine's 2024 SPM-like rate is much higher than Census's 2024 SPM rate.", @@ -1314,11 +1314,14 @@ "ANN_VAL" ], "enhanced_variables": [ - "pension_income" + "taxable_private_pension_income", + "tax_exempt_private_pension_income", + "taxable_public_pension_income", + "tax_exempt_public_pension_income" ], "raw_mean": 1738, - "enhanced_mean": 0, - "difference": -1738, + "enhanced_mean": 0.2, + "difference": -1737.8, "enhanced_available": true, "note": null }, @@ -1667,14 +1670,17 @@ "spm_unit_taxes": 24294, "spm_unit_spm_expenses": 10120, "spm_unit_medical_out_of_pocket_expenses": 7656, + "pension_income": 0.2, "child_support_received": 30, "workers_compensation": 21, "miscellaneous_income": 77, - "alimony_income": 0, + "alimony_income": 0.01, "strike_benefits": 0, "educational_assistance": 0, "financial_assistance": 0, "survivor_benefits": 0, + "spm_unit_capped_housing_subsidy": 0, + "housing_assistance": 0, "spm_unit_energy_subsidy": 0, "note": "Weighted person-average values after mapping variables to people." }, diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 2a3ff08..bba8e1e 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -11,9 +11,9 @@ const inter = Inter({ }); export const metadata: Metadata = { - title: "PolicyEngine poverty dashboard", + title: "PolicyEngine 2024 SPM comparison", description: - "Internal dashboard tracking baseline federal and per-state poverty and child poverty rates from PolicyEngine-US.", + "Internal dashboard comparing PolicyEngine-US 2024 SPM-like results with Census SPM report benchmarks.", icons: { icon: "/favicon.svg" }, }; diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 194eb58..1f80890 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -17,6 +17,7 @@ import { VersionBar } from "@/components/VersionBar"; import { DownloadJSONButton } from "@/components/DownloadJSONButton"; import { CensusEffects } from "@/components/CensusEffects"; import { GapDiagnostics } from "@/components/GapDiagnostics"; +import { Census2024Focus } from "@/components/Census2024Focus"; const EMPTY_BASELINE: Baseline = { generated_at: null, @@ -29,7 +30,7 @@ const EMPTY_BASELINE: Baseline = { const AVAILABLE_YEARS = [2024, 2025, 2026]; export default function Page() { - const [selectedYear, setSelectedYear] = useState(2026); + const [exploreYear, setExploreYear] = useState(2026); const committedQuery = useQuery({ queryKey: ["committed-baseline"], queryFn: fetchCommittedBaseline, @@ -38,9 +39,9 @@ export default function Page() { const [override, setOverride] = useState(null); const sourceBaseline = override ?? committedQuery.data ?? EMPTY_BASELINE; const baseline = - sourceBaseline.year === selectedYear + sourceBaseline.year === exploreYear ? sourceBaseline - : { ...EMPTY_BASELINE, year: selectedYear }; + : { ...EMPTY_BASELINE, year: exploreYear }; const censusQuery = useQuery({ queryKey: ["census-spm-2024"], @@ -72,44 +73,9 @@ export default function Page() { return (
- { - setSelectedYear(year); - setOverride(null); - }} - onRefreshVersions={() => versionsQuery.refetch()} - onRecompute={() => recomputeMut.mutate({ upgrade: false, year: selectedYear })} - onRecomputeUpgrade={() => recomputeMut.mutate({ upgrade: true, year: selectedYear })} - recomputing={recomputeMut.isPending} - /> - - {recomputeMut.isError && ( -
- Recompute failed: {String(recomputeMut.error)} -
- )} - - {override && ( -
- - Showing freshly computed numbers (not yet committed). Generated{" "} - {override.generated_at}. - - -
- )} - - @@ -119,35 +85,88 @@ export default function Page() { diagnostics={diagnosticsQuery.data ?? null} /> -
-
-

By state

- {errorCount > 0 && ( - - {errorCount} region{errorCount === 1 ? "" : "s"} failed - - )} +
+
+

+ Explore other years +

+
+ Baseline poverty runs for non-Census-comparison years. +
- { + setExploreYear(year); + setOverride(null); + }} + onRefreshVersions={() => versionsQuery.refetch()} + onRecompute={() => recomputeMut.mutate({ upgrade: false, year: exploreYear })} + onRecomputeUpgrade={() => + recomputeMut.mutate({ upgrade: true, year: exploreYear }) + } + recomputing={recomputeMut.isPending} + /> + + {recomputeMut.isError && ( +
+ Recompute failed: {String(recomputeMut.error)} +
+ )} + + {override && ( +
+ + Showing freshly computed numbers (not yet committed). Generated{" "} + {override.generated_at}. + + +
+ )} + + -
- {errorCount > 0 && ( -
- - Errors ({errorCount}) - -
    - {baseline.errors.map((e) => ( -
  • - {e.region_code}: {e.error} -
  • - ))} -
-
- )} +
+
+

By state

+ {errorCount > 0 && ( + + {errorCount} region{errorCount === 1 ? "" : "s"} failed + + )} +
+ +
+ + {errorCount > 0 && ( +
+ + Errors ({errorCount}) + +
    + {baseline.errors.map((e) => ( +
  • + {e.region_code}: {e.error} +
  • + ))} +
+
+ )} +
); } diff --git a/frontend/components/Census2024Focus.tsx b/frontend/components/Census2024Focus.tsx new file mode 100644 index 0000000..ad8f87a --- /dev/null +++ b/frontend/components/Census2024Focus.tsx @@ -0,0 +1,243 @@ +"use client"; + +import type { + CensusSpmReport, + RawSpmResourceFormulaComponent, + SpmGapDiagnostics, + TotalIncomeLeafDiagnostics, +} from "@/lib/types"; +import { dollars, num, pct, shortDataset } from "@/lib/format"; + +type Props = { + census: CensusSpmReport | null; + diagnostics: SpmGapDiagnostics | null; +}; + +type InputGapRow = { + key: string; + label: string; + source: string; + rawMean: number; + peMean: number | null; + difference: number | null; + variables: string[]; +}; + +function pp(value: number | null | undefined, digits = 1): string { + if (value === null || value === undefined || Number.isNaN(value)) return "—"; + return `${(value * 100).toFixed(digits)} pp`; +} + +function leafRow( + totalIncome: TotalIncomeLeafDiagnostics | undefined, + key: string, +): InputGapRow | null { + const row = totalIncome?.component_mean_gaps.find((item) => item.key === key); + if (!row) return null; + return { + key: row.key, + label: row.label, + source: row.raw_columns.join(" + "), + rawMean: row.raw_mean, + peMean: row.enhanced_mean, + difference: row.difference, + variables: row.enhanced_variables, + }; +} + +function formulaRow( + totalIncome: TotalIncomeLeafDiagnostics | undefined, + key: string, +): InputGapRow | null { + const row = totalIncome?.spm_resource_formula?.components.find( + (item: RawSpmResourceFormulaComponent) => item.key === key, + ); + if (!row) return null; + return { + key: row.key, + label: row.label, + source: row.raw_columns.join(" + "), + rawMean: row.raw_mean, + peMean: row.enhanced_mean, + difference: row.difference, + variables: row.enhanced_variables, + }; +} + +function Stat({ + label, + value, + sub, +}: { + label: string; + value: string; + sub?: string; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+ {sub ?
{sub}
: null} +
+ ); +} + +export function Census2024Focus({ census, diagnostics }: Props) { + if (!census || !diagnostics) return null; + + const totalIncome = diagnostics.total_income_leaf_diagnostics; + const gap = diagnostics.gap_accounting; + const pe2024 = diagnostics.policyengine_2024_checks[0]; + const rawControl = + diagnostics.source_replication_diagnostics?.sources.raw_cps_asec; + const enhancedDataset = + diagnostics.source_replication_diagnostics?.sources.enhanced_cps.dataset_path; + const inputGapRows = [ + leafRow(totalIncome, "pension_income"), + formulaRow(totalIncome, "housing_subsidy"), + formulaRow(totalIncome, "energy_assistance"), + ].filter((row): row is InputGapRow => row !== null); + + return ( +
+
+
+

+ 2024 Census SPM comparison +

+
+ Main view for reconciling PolicyEngine 2024 SPM-like results with Census P60-287. +
+
+ + Census report tables + +
+ + {gap ? ( +
+
+ + + + +
+
+ ) : ( +
+
+ + +
+
+ )} + +
+
+
+ Current ECPS input gaps +
+
+ Raw Census means compared with current PE ECPS inputs + {enhancedDataset ? ` (${shortDataset(enhancedDataset)})` : ""}. +
+
+
+ + + + + + + + + + + + {inputGapRows.map((row) => ( + + + + + + + + ))} + +
+ Component + + Mapping + + Raw Census + + PE ECPS + + Gap +
+
+ {row.label} +
+
+
+ {row.source} -> {row.variables.join(" + ")} +
+
+ {dollars(row.rawMean)} + + {dollars(row.peMean)} + + {dollars(row.difference)} +
+
+
+ These rows explain why pension, housing subsidy, and energy subsidy are currently + near zero in the released ECPS-side comparison. +
+
+ + {rawControl?.rates ? ( +
+ Raw CPS with Census-reported SPM resources gives an all-person rate of{" "} + + {pct(rawControl.rates.all)} + + , so the remaining PE/Census gap is mainly upstream of the SPM formula comparison. +
+ ) : null} +
+ ); +} diff --git a/frontend/components/FederalCard.tsx b/frontend/components/FederalCard.tsx index 4bac0ca..52a7e8b 100644 --- a/frontend/components/FederalCard.tsx +++ b/frontend/components/FederalCard.tsx @@ -45,7 +45,7 @@ function CensusComparison({ federal: RegionResult | undefined; year: number; }) { - if (!census) return null; + if (!census || year !== census.report_year) return null; return (
diff --git a/frontend/components/GapDiagnostics.tsx b/frontend/components/GapDiagnostics.tsx index 5f52548..93fe6af 100644 --- a/frontend/components/GapDiagnostics.tsx +++ b/frontend/components/GapDiagnostics.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import type { SpmGapDiagnostics, ThresholdRatioDistribution } from "@/lib/types"; -import { num, pct } from "@/lib/format"; +import { dollars, num, pct } from "@/lib/format"; type Props = { diagnostics: SpmGapDiagnostics | null; @@ -20,11 +20,6 @@ const AGE_COLUMNS: { { key: "senior", label: "65+", description: "Ages 65 and older" }, ]; -function dollars(value: number | string | null | undefined): string { - if (typeof value !== "number") return "—"; - return `$${num(value)}`; -} - function pp(value: number | null | undefined): string { if (value === null || value === undefined || Number.isNaN(value)) return "—"; return `${(value * 100).toFixed(1)} pp`; diff --git a/frontend/components/Header.tsx b/frontend/components/Header.tsx index 64edfd4..0f94857 100644 --- a/frontend/components/Header.tsx +++ b/frontend/components/Header.tsx @@ -39,7 +39,7 @@ export function PageHeader() {

- Baseline poverty dashboard + 2024 SPM comparison dashboard

diff --git a/frontend/lib/format.ts b/frontend/lib/format.ts index 7746a84..29f60c8 100644 --- a/frontend/lib/format.ts +++ b/frontend/lib/format.ts @@ -8,6 +8,12 @@ export function num(x: number | null | undefined): string { return new Intl.NumberFormat("en-US").format(Math.round(x)); } +export function dollars(x: number | string | null | undefined): string { + if (typeof x !== "number" || Number.isNaN(x)) return "—"; + if (x !== 0 && Math.abs(x) < 1) return `$${x.toFixed(2)}`; + return `$${num(x)}`; +} + export function shortDataset(path: string | null | undefined): string { if (!path) return "—"; return path.replace("hf://policyengine/policyengine-us-data/", ""); diff --git a/poverty_dashboard/spm_diagnostics.py b/poverty_dashboard/spm_diagnostics.py index 9b3cdcf..c59d746 100644 --- a/poverty_dashboard/spm_diagnostics.py +++ b/poverty_dashboard/spm_diagnostics.py @@ -79,6 +79,7 @@ "spm_unit_taxes", "spm_unit_spm_expenses", "spm_unit_medical_out_of_pocket_expenses", + "pension_income", "child_support_received", "workers_compensation", "miscellaneous_income", @@ -87,6 +88,8 @@ "educational_assistance", "financial_assistance", "survivor_benefits", + "spm_unit_capped_housing_subsidy", + "housing_assistance", "spm_unit_energy_subsidy", ) @@ -175,7 +178,12 @@ "key": "pension_income", "label": "Pensions and annuities", "raw_columns": ("PNSN_VAL", "ANN_VAL"), - "enhanced_variables": ("pension_income",), + "enhanced_variables": ( + "taxable_private_pension_income", + "tax_exempt_private_pension_income", + "taxable_public_pension_income", + "tax_exempt_public_pension_income", + ), }, { "key": "other_income", @@ -493,15 +501,15 @@ def _rate_check(label: str, poverty_status: Any, age: MicroSeries, note: str) -> } -def _resource_means(sim: Any, year: int) -> dict[str, int | str]: - means: dict[str, int | str] = {} +def _resource_means(sim: Any, year: int) -> dict[str, float | str]: + means: dict[str, float | str] = {} for variable in RESOURCE_MEAN_VARIABLES: try: value = sim.calculate(variable, period=year, map_to="person") except Exception as error: means[variable] = f"{type(error).__name__}: {error}" continue - means[variable] = round(float(value.mean())) + means[variable] = _round_mean_amount(float(value.mean())) means["note"] = "Weighted person-average values after mapping variables to people." return means @@ -937,6 +945,14 @@ def _weighted_mean(values: Any, weights: Any) -> float: return float(MicroSeries(values, weights=weights).mean()) +def _round_mean_amount(value: float) -> float: + if value == 0: + return 0 + if abs(value) < 1: + return round(value, 2) + return round(value) + + def _reconstruction_metrics(values: Any, target: Any, weights: Any) -> dict[str, Any]: residual = pd.Series(values).to_numpy(dtype=float) - pd.Series(target).to_numpy( dtype=float @@ -969,7 +985,7 @@ def _enhanced_total_income_leaf_mean( if total is None: return None, "No PolicyEngine variables configured." - return round(float(total.mean())), None + return _round_mean_amount(float(total.mean())), None def _raw_spm_resource_formula_summary( @@ -990,7 +1006,7 @@ def _raw_spm_resource_formula_summary( formula_resource -= unit_amount raw_values = person[raw_columns].sum(axis=1) - raw_mean = round(_weighted_mean(raw_values, person["SPM_WEIGHT"])) + raw_mean = _round_mean_amount(_weighted_mean(raw_values, person["SPM_WEIGHT"])) enhanced_mean, error = _enhanced_total_income_leaf_mean( enhanced_sim, component["enhanced_variables"], @@ -1084,7 +1100,7 @@ def compute_total_income_leaf_diagnostics( components: list[dict[str, Any]] = [] for component in CPS_TOTAL_INCOME_LEAF_COMPONENTS: raw_values = person[list(component["raw_columns"])].sum(axis=1) - raw_mean = round(_weighted_mean(raw_values, person["A_FNLWGT"])) + raw_mean = _round_mean_amount(_weighted_mean(raw_values, person["A_FNLWGT"])) enhanced_mean, error = _enhanced_total_income_leaf_mean( enhanced_sim, component["enhanced_variables"], diff --git a/tests/test_spm_diagnostics.py b/tests/test_spm_diagnostics.py index 0160f6b..1ceb42f 100644 --- a/tests/test_spm_diagnostics.py +++ b/tests/test_spm_diagnostics.py @@ -69,7 +69,10 @@ def calculate( assert map_to == "person" values = { "employment_income": [10, 20], - "pension_income": [3, 0], + "taxable_private_pension_income": [2, 0], + "tax_exempt_private_pension_income": [1, 0], + "taxable_public_pension_income": [0, 0], + "tax_exempt_public_pension_income": [0, 0], "miscellaneous_income": [4, 0], "alimony_income": [1, 1], "strike_benefits": [0, 2], @@ -329,6 +332,12 @@ def test__given_raw_asec_leaves__then_spm_totval_is_reconstructed(monkeypatch): ) assert pension["raw_mean"] == 4 assert pension["enhanced_mean"] == 2 + assert pension["enhanced_variables"] == [ + "taxable_private_pension_income", + "tax_exempt_private_pension_income", + "taxable_public_pension_income", + "tax_exempt_public_pension_income", + ] other = next( row for row in result["component_mean_gaps"] if row["key"] == "other_income" )