diff --git a/cliff_watch/calculator.py b/cliff_watch/calculator.py index 932dda6..adfbdd6 100644 --- a/cliff_watch/calculator.py +++ b/cliff_watch/calculator.py @@ -21,6 +21,7 @@ DEFAULT_SERIES_TARGET_POINTS, DEFAULT_YEAR, FILING_STATUS_OPTIONS, + HOUSEHOLD_COST_DEFINITIONS, MARRIED_FILING_STATUSES, HOUSEHOLD_TYPE_BY_ID, PROGRAM_DEFINITIONS, @@ -55,6 +56,9 @@ class HouseholdInput: PROGRAM_LABEL_BY_KEY = {item["key"]: item["label"] for item in PROGRAM_DEFINITIONS} +HOUSEHOLD_COST_LABEL_BY_KEY = { + item["key"]: item["label"] for item in HOUSEHOLD_COST_DEFINITIONS +} FILING_STATUS_CODES = {item["code"] for item in FILING_STATUS_OPTIONS} REFUNDABLE_CREDIT_COMPONENTS = ( {"key": "eitc", "variable": "eitc", "map_to": "tax_unit"}, @@ -125,10 +129,6 @@ def _candidate_policyengine_repo() -> Path | None: repo = os.getenv("POLICYENGINE_US_REPO") if repo: return Path(repo).expanduser() - - sibling = Path(__file__).resolve().parents[1].parent / "policyengine-us" - if sibling.exists(): - return sibling return None @@ -1000,6 +1000,24 @@ def _build_cliff_drivers( } ) + for key, label in HOUSEHOLD_COST_LABEL_BY_KEY.items(): + annual_change = round( + result["household_costs"][key] - previous_result["household_costs"][key], + 2, + ) + if annual_change > 0: + drivers.append( + { + "key": key, + "label": label, + "kind": "household_cost_increase", + "raw_change_annual": annual_change, + "raw_change_monthly": _monthly_amount(annual_change), + "resource_effect_annual": round(-annual_change, 2), + "resource_effect_monthly": _monthly_amount(-annual_change), + } + ) + tax_change = round( result["totals"]["taxes"] - previous_result["totals"]["taxes"], 2, diff --git a/frontend/package.json b/frontend/package.json index 3cc686f..2c1179e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "test": "node --test src/policyengineApi.test.js", "build": "vite build", "preview": "vite preview" }, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a22a439..eacac9c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -10,6 +10,19 @@ import { import { decodeInputs, syncUrlToInputs } from './utils/urlState' import { refineCliffZones } from './utils/seriesRefine' +function cleanSeriesErrorMessage(error) { + const message = error?.message?.trim() + if (!message) { + return 'The cliff chart is unavailable right now.' + } + + if (message.startsWith('Calculation failed:')) { + return message + } + + return `Chart calculation failed: ${message}` +} + function App() { const [metadata, setMetadata] = useState(null) const [inputs, setInputs] = useState(null) @@ -91,23 +104,27 @@ function App() { const isCancelled = () => requestVersion !== requestVersionRef.current let primary = null + let primaryError = null try { primary = await calculateSeries(nextInputs, metadata, { step: defaultStep }) } catch (err) { + primaryError = err console.error(err) } if (isCancelled()) return if (!primary) { + let fallbackError = null try { primary = await calculateSeries(nextInputs, metadata, { step: fallbackStep }) if (isCancelled()) return setSeriesError('Sampled coarsely for speed; refining around detected cliffs.') } catch (err) { + fallbackError = err console.error(err) if (isCancelled()) return - setSeriesError('The cliff chart timed out. Try a smaller chart max and run it again.') + setSeriesError(cleanSeriesErrorMessage(fallbackError || primaryError)) setSeriesLoading(false) return } diff --git a/frontend/src/components/BenefitChart.jsx b/frontend/src/components/BenefitChart.jsx index 962f675..4be4cc8 100644 --- a/frontend/src/components/BenefitChart.jsx +++ b/frontend/src/components/BenefitChart.jsx @@ -63,37 +63,22 @@ const PROGRAM_DETAIL_LINE_SERIES = [ }, ] -const PROGRAM_DETAIL_AREA_SERIES = [ - { - key: 'earned_income_annual', - label: 'Wages and salaries', - type: 'area', - stroke: '#6B7B93', - fill: '#D8E1EC', - defaultVisible: true, - }, - { - key: 'federal_taxes_before_refundable_credits_annual', - label: 'Federal taxes', - type: 'area', - stroke: '#DC2626', - fill: '#FECACA', - defaultVisible: true, - hideStroke: true, - }, - { - key: 'state_taxes_before_refundable_credits_annual', - label: 'State taxes', - type: 'area', - stroke: '#B91C1C', - fill: '#FCA5A5', - defaultVisible: true, - hideStroke: true, - }, +const PROGRAM_DETAIL_EARNINGS_SERIES = { + key: 'earned_income_annual', + label: 'Wages and salaries', + type: 'area', + family: 'earnings', + stroke: '#6B7B93', + fill: '#D8E1EC', + defaultVisible: true, +} + +const PROGRAM_DETAIL_SUPPORT_SERIES = [ { key: 'federal_refundable_credits_annual', label: 'Federal refundable tax credits', type: 'area', + family: 'support', stroke: '#7C3AED', fill: '#DDD6FE', defaultVisible: true, @@ -102,6 +87,7 @@ const PROGRAM_DETAIL_AREA_SERIES = [ key: 'state_refundable_credits_annual', label: 'State refundable tax credits', type: 'area', + family: 'support', stroke: '#6D28D9', fill: '#C4B5FD', defaultVisible: true, @@ -110,6 +96,7 @@ const PROGRAM_DETAIL_AREA_SERIES = [ key: 'tanf_annual', label: 'TANF', type: 'area', + family: 'support', stroke: '#9A5A3C', fill: '#D7AE8E', defaultVisible: true, @@ -118,6 +105,7 @@ const PROGRAM_DETAIL_AREA_SERIES = [ key: 'snap_annual', label: 'SNAP', type: 'area', + family: 'support', stroke: '#4A9B68', fill: '#9BD3A8', defaultVisible: true, @@ -126,6 +114,7 @@ const PROGRAM_DETAIL_AREA_SERIES = [ key: 'wic_annual', label: 'WIC', type: 'area', + family: 'support', stroke: '#D17AA4', fill: '#F1C4D8', defaultVisible: true, @@ -134,6 +123,7 @@ const PROGRAM_DETAIL_AREA_SERIES = [ key: 'free_school_meals_annual', label: 'School meals', type: 'area', + family: 'support', stroke: '#C9963E', fill: '#F0D29E', defaultVisible: true, @@ -142,6 +132,7 @@ const PROGRAM_DETAIL_AREA_SERIES = [ key: 'child_care_subsidies_annual', label: 'Child care subsidies', type: 'area', + family: 'support', stroke: '#8B5CF6', fill: '#DDD6FE', defaultVisible: true, @@ -150,6 +141,7 @@ const PROGRAM_DETAIL_AREA_SERIES = [ key: 'medicaid_annual', label: 'Medicaid', type: 'area', + family: 'support', stroke: '#0F766E', fill: '#A7E4DB', defaultVisible: true, @@ -158,6 +150,7 @@ const PROGRAM_DETAIL_AREA_SERIES = [ key: 'chip_annual', label: 'CHIP', type: 'area', + family: 'support', stroke: '#6366F1', fill: '#C7D2FE', defaultVisible: true, @@ -166,12 +159,42 @@ const PROGRAM_DETAIL_AREA_SERIES = [ key: 'aca_ptc_annual', label: 'ACA', type: 'area', + family: 'support', stroke: '#3B82F6', fill: '#BFDBFE', defaultVisible: true, }, ] +const PROGRAM_DETAIL_TAX_SERIES = [ + { + key: 'federal_taxes_before_refundable_credits_annual', + label: 'Federal taxes', + type: 'area', + family: 'tax', + stroke: '#DC2626', + fill: '#FECACA', + defaultVisible: true, + hideStroke: true, + }, + { + key: 'state_taxes_before_refundable_credits_annual', + label: 'State taxes', + type: 'area', + family: 'tax', + stroke: '#B91C1C', + fill: '#FCA5A5', + defaultVisible: true, + hideStroke: true, + }, +] + +const HOUSEHOLD_COST_SERIES_COLORS = [ + { stroke: '#9F1239', fill: '#FBCFE8' }, + { stroke: '#BE185D', fill: '#F9A8D4' }, + { stroke: '#831843', fill: '#FDA4AF' }, +] + const CLIFF_HIGHLIGHT_STYLES = { severe: { stroke: '#DC2626', @@ -190,22 +213,41 @@ const CLIFF_HIGHLIGHT_STYLES = { }, } -const DETAIL_TOOLTIP_ORDER = [ - 'earned_income_annual', - 'federal_refundable_credits_annual', - 'state_refundable_credits_annual', - 'tanf_annual', - 'snap_annual', - 'wic_annual', - 'free_school_meals_annual', - 'child_care_subsidies_annual', - 'medicaid_annual', - 'chip_annual', - 'aca_ptc_annual', - 'federal_taxes_before_refundable_credits_annual', - 'state_taxes_before_refundable_credits_annual', +const DEFAULT_HOUSEHOLD_COST_DEFINITIONS = [ + { + key: 'chip_premium', + label: 'CHIP premium', + }, ] +function getHouseholdCostDefinitions(metadata) { + const definitions = metadata?.household_costs + if (Array.isArray(definitions) && definitions.length) { + return definitions + } + return DEFAULT_HOUSEHOLD_COST_DEFINITIONS +} + +function getPointHouseholdCostValue(point, key) { + return Number(point?.household_costs?.[key] ?? point?.[key]) || 0 +} + +function buildHouseholdCostAreaSeries(metadata) { + return getHouseholdCostDefinitions(metadata).map((cost, index) => { + const colors = HOUSEHOLD_COST_SERIES_COLORS[index % HOUSEHOLD_COST_SERIES_COLORS.length] + return { + key: `household_cost_${cost.key}_annual`, + label: cost.label, + type: 'area', + family: 'household_cost', + stroke: colors.stroke, + fill: colors.fill, + defaultVisible: true, + hideStroke: true, + } + }) +} + function chooseNiceStep(rawStep) { const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))) const normalized = rawStep / magnitude @@ -242,6 +284,7 @@ function BenefitChart({ data, loading = false, placeholderMaxEarnedIncome = 100000, + metadata, }) { const [chartMode, setChartMode] = useState('net_income') const [netVisibleKeys, setNetVisibleKeys] = useState({}) @@ -249,6 +292,14 @@ function BenefitChart({ const [showCliffHighlights, setShowCliffHighlights] = useState(true) const [showMtr, setShowMtr] = useState(false) const hasRealData = Boolean(data?.length) + const householdCostDefinitions = useMemo( + () => getHouseholdCostDefinitions(metadata), + [metadata], + ) + const householdCostAreaSeries = useMemo( + () => buildHouseholdCostAreaSeries(metadata), + [metadata], + ) const annualizedData = useMemo(() => { const rawPoints = data || [] @@ -259,6 +310,7 @@ function BenefitChart({ net_resources_annual: 0, detail_net_income_annual: 0, benefits_only_annual: 0, + household_costs_total_annual: 0, federal_refundable_credits_annual: 0, state_refundable_credits_annual: 0, federal_taxes_annual: 0, @@ -272,6 +324,7 @@ function BenefitChart({ net_resources_annual: 0, detail_net_income_annual: 0, benefits_only_annual: 0, + household_costs_total_annual: 0, federal_refundable_credits_annual: 0, state_refundable_credits_annual: 0, federal_taxes_annual: 0, @@ -290,14 +343,26 @@ function BenefitChart({ const stateRefundableCreditsAnnual = Number(point.state_refundable_credits || 0) const federalTaxesAnnual = Number(point.federal_taxes_before_refundable_credits || 0) const stateTaxesAnnual = Number(point.state_taxes_before_refundable_credits || 0) + const totalHouseholdCostsAnnual = householdCostDefinitions.reduce( + (sum, definition) => sum + getPointHouseholdCostValue(point, definition.key), + 0, + ) + const householdCostValues = Object.fromEntries( + householdCostDefinitions.map((definition) => ([ + `household_cost_${definition.key}_annual`, + -getPointHouseholdCostValue(point, definition.key), + ])), + ) return { ...point, + ...householdCostValues, earned_income_annual: Number(point.earned_income || 0), net_resources_annual: Number(point.net_resources || 0), detail_net_income_annual: Number(point.net_resources || 0), benefits_and_credits_annual: coreBenefitsAnnual, benefits_only_annual: coreBenefitsAnnual - federalRefundableCreditsAnnual - stateRefundableCreditsAnnual, + household_costs_total_annual: totalHouseholdCostsAnnual, taxes_annual: taxesAnnual, federal_taxes_annual: federalTaxesAnnual, state_taxes_annual: stateTaxesAnnual, @@ -343,30 +408,31 @@ function BenefitChart({ upcoming_cliff_drivers: filterMaterialCliffDrivers(nextPoint.cliff_drivers || []), } }) - }, [data, loading, placeholderMaxEarnedIncome]) + }, [data, householdCostDefinitions, loading, placeholderMaxEarnedIncome]) const detailAreaSeries = useMemo(() => ( - PROGRAM_DETAIL_AREA_SERIES.filter((series) => ( + [ + PROGRAM_DETAIL_EARNINGS_SERIES, + ...PROGRAM_DETAIL_SUPPORT_SERIES, + ...householdCostAreaSeries, + ...PROGRAM_DETAIL_TAX_SERIES, + ].filter((series) => ( annualizedData.some((item) => Math.abs(Number(item[series.key] || 0)) > 0) )) - ), [annualizedData]) + ), [annualizedData, householdCostAreaSeries]) const detailLegendSeries = useMemo(() => { const earningsSeries = detailAreaSeries.find((series) => series.key === 'earned_income_annual') - const federalTaxesSeries = detailAreaSeries.find((series) => series.key === 'federal_taxes_before_refundable_credits_annual') - const stateTaxesSeries = detailAreaSeries.find((series) => series.key === 'state_taxes_before_refundable_credits_annual') - const remainingAreaSeries = detailAreaSeries.filter((series) => ( - series.key !== 'earned_income_annual' - && series.key !== 'federal_taxes_before_refundable_credits_annual' - && series.key !== 'state_taxes_before_refundable_credits_annual' - )) + const supportSeries = detailAreaSeries.filter((series) => series.family === 'support') + const householdCostSeries = detailAreaSeries.filter((series) => series.family === 'household_cost') + const taxSeries = detailAreaSeries.filter((series) => series.family === 'tax') return [ PROGRAM_DETAIL_LINE_SERIES[0], earningsSeries, - federalTaxesSeries, - stateTaxesSeries, - ...remainingAreaSeries, + ...supportSeries, + ...householdCostSeries, + ...taxSeries, ].filter(Boolean) }, [detailAreaSeries]) @@ -575,6 +641,12 @@ function BenefitChart({ State refundable tax credits {fmt(point.state_refundable_credits_annual)}/yr + {point.household_costs_total_annual > 0 ? ( +
+ Household costs + {fmt(point.household_costs_total_annual)}/yr +
+ ) : null}
Federal taxes {fmt(point.federal_taxes_annual)}/yr @@ -628,11 +700,10 @@ function BenefitChart({ const point = payload[0].payload const tooltipCliff = getTooltipCliff(point) - const seriesByKey = new Map(detailAreaSeries.map((series) => [series.key, series])) - const activeSeries = DETAIL_TOOLTIP_ORDER - .map((key) => seriesByKey.get(key)) + const activeSeries = detailLegendSeries .filter((series) => ( Boolean(series) + && series.type === 'area' && detailVisibleKeys[series.key] && Math.abs(Number(point[series.key] || 0)) > 0 )) @@ -687,7 +758,7 @@ function BenefitChart({

{chartMode === 'net_income' ? 'Track how annual net income changes as wages and salaries rise, with earnings dead zones shaded and cliff markers anchored to the last sampled income before a drop.' - : 'Turn programs on and off to see wages and salaries and supports above zero, federal and state taxes below zero, and the black line showing final annual net income.'} + : 'Turn programs on and off to see wages and salaries and supports above zero, household costs and taxes below zero, and the black line showing final annual net income.'}

diff --git a/frontend/src/components/CliffInsights.jsx b/frontend/src/components/CliffInsights.jsx index 30fa7f6..78fa170 100644 --- a/frontend/src/components/CliffInsights.jsx +++ b/frontend/src/components/CliffInsights.jsx @@ -27,7 +27,7 @@ function CliffInsights({ data, stepAnnual }) { .slice(0, MAX_ZONE_CARDS) ), [report.zones]) - const thresholdCopy = `${formatCurrency(report.thresholds?.minDropAnnual || 0)}/yr net loss or any single program loss of ${formatCurrency(report.thresholds?.minDriverLossAnnual || 0)}/yr` + const thresholdCopy = `${formatCurrency(report.thresholds?.minDropAnnual || 0)}/yr net loss or any single benefit loss or household cost increase of ${formatCurrency(report.thresholds?.minDriverLossAnnual || 0)}/yr` if (report.cliffs.length === 0) { return ( diff --git a/frontend/src/components/ResultsPanel.jsx b/frontend/src/components/ResultsPanel.jsx index 2170512..0a1f7b2 100644 --- a/frontend/src/components/ResultsPanel.jsx +++ b/frontend/src/components/ResultsPanel.jsx @@ -102,6 +102,7 @@ function ResultsPanel({ {seriesError &&

{seriesError}

} ) : ( diff --git a/frontend/src/policyengineApi.js b/frontend/src/policyengineApi.js index 5d70d45..25dd07a 100644 --- a/frontend/src/policyengineApi.js +++ b/frontend/src/policyengineApi.js @@ -75,6 +75,15 @@ const DEFAULT_CCDF_MODELED_STATES = new Set([ 'CA', 'CO', 'DE', 'MA', 'ME', 'NE', 'NH', 'PA', 'RI', 'VT', ]) +const DEFAULT_HOUSEHOLD_COST_DEFINITIONS = [ + { + key: 'chip_premium', + label: 'CHIP premium', + short_label: 'CHIP premium', + description: 'Annual CHIP premium or enrollment fee paid by the household.', + }, +] + function isCcdfModeledState(state, metadata) { const fromMetadata = metadata?.ccdf_modeled_states if (Array.isArray(fromMetadata) && fromMetadata.length) { @@ -394,6 +403,7 @@ function buildSituation(payload, options = {}) { household_tax_before_refundable_credits: { [year]: null }, household_state_tax_before_refundable_credits: { [year]: null }, household_refundable_state_tax_credits: { [year]: null }, + chip_premium: { [year]: null }, }, }, marital_units: {}, @@ -569,12 +579,30 @@ function getProgramDefinitions(metadata) { return metadata?.programs || [] } +function getHouseholdCostDefinitions(metadata) { + const definitions = metadata?.household_costs + if (Array.isArray(definitions) && definitions.length) { + return definitions + } + return DEFAULT_HOUSEHOLD_COST_DEFINITIONS +} + function getProgramLabelMap(metadata) { return Object.fromEntries( getProgramDefinitions(metadata).map((program) => [program.key, program.label]), ) } +function getHouseholdCostLabelMap(metadata) { + return Object.fromEntries( + getHouseholdCostDefinitions(metadata).map((cost) => [cost.key, cost.label]), + ) +} + +function getHouseholdCostValue(point, key) { + return Number(point?.household_costs?.[key] ?? point?.[key]) || 0 +} + function bestAccessProgram({ acaEligible, medicaidEligible, chipEligible }) { if (medicaidEligible) return 'medicaid' if (chipEligible) return 'chip' @@ -604,7 +632,7 @@ function formatProgramBreakdown(programs, metadata) { .filter(Boolean) } -function buildHouseholdResultFromResponse(payload, metadata, apiResponse, descriptor) { +export function buildHouseholdResultFromResponse(payload, metadata, apiResponse, descriptor) { const year = String(payload.year) const households = apiResponse?.result?.households?.household || {} const taxUnit = apiResponse?.result?.tax_units?.tax_unit || {} @@ -644,6 +672,7 @@ function buildHouseholdResultFromResponse(payload, metadata, apiResponse, descri const stateRefundableCredits = roundCurrency( getYearValue(households, 'household_refundable_state_tax_credits', year), ) + const chipPremium = roundCurrency(getYearValue(households, 'chip_premium', year)) const tanf = roundCurrency( tanfVariable ? (Number(getMonthValue(spmUnit, tanfVariable, year)) || 0) * 12 @@ -665,6 +694,9 @@ function buildHouseholdResultFromResponse(payload, metadata, apiResponse, descri federal_refundable_credits: federalRefundableCredits, state_refundable_credits: stateRefundableCredits, } + const householdCosts = { + chip_premium: chipPremium, + } const people = descriptor.people.map((person) => { const apiPerson = peopleResponse[person.id] || {} @@ -703,7 +735,10 @@ function buildHouseholdResultFromResponse(payload, metadata, apiResponse, descri const coreSupport = roundCurrency( Object.values(programs).reduce((sum, value) => sum + value, 0), ) - const netResources = roundCurrency(marketIncome + coreSupport - taxes) + const totalHouseholdCosts = roundCurrency( + Object.values(householdCosts).reduce((sum, value) => sum + value, 0), + ) + const netResources = roundCurrency(marketIncome + coreSupport - taxes - totalHouseholdCosts) return { input: { @@ -733,9 +768,11 @@ function buildHouseholdResultFromResponse(payload, metadata, apiResponse, descri federal_taxes_before_refundable_credits: federalTaxesBeforeRefundableCredits, state_taxes_before_refundable_credits: stateTaxesBeforeRefundableCredits, core_support: coreSupport, + household_costs: totalHouseholdCosts, net_resources: netResources, }, programs, + household_costs: householdCosts, access, context: { tax_unit_fpg: taxUnitFpg, @@ -750,13 +787,15 @@ function buildHouseholdResultFromResponse(payload, metadata, apiResponse, descri market_income: monthlyAmount(marketIncome), taxes: monthlyAmount(taxes), core_support: monthlyAmount(coreSupport), + household_costs: monthlyAmount(totalHouseholdCosts), net_resources: monthlyAmount(netResources), }, } } -function buildCliffDrivers(previousPoint, currentPoint, metadata) { +export function buildCliffDrivers(previousPoint, currentPoint, metadata) { const labelByKey = getProgramLabelMap(metadata) + const householdCostLabels = getHouseholdCostLabelMap(metadata) const drivers = Object.keys(labelByKey).flatMap((key) => { const changeAnnual = roundCurrency(currentPoint.programs[key] - previousPoint.programs[key]) if (changeAnnual >= 0) { @@ -773,6 +812,24 @@ function buildCliffDrivers(previousPoint, currentPoint, metadata) { }] }) + Object.keys(householdCostLabels).forEach((key) => { + const changeAnnual = roundCurrency( + getHouseholdCostValue(currentPoint, key) - getHouseholdCostValue(previousPoint, key), + ) + if (changeAnnual <= 0) { + return + } + drivers.push({ + key, + label: householdCostLabels[key], + kind: 'household_cost_increase', + raw_change_annual: changeAnnual, + raw_change_monthly: monthlyAmount(changeAnnual), + resource_effect_annual: roundCurrency(-changeAnnual), + resource_effect_monthly: monthlyAmount(-changeAnnual), + }) + }) + const taxChangeAnnual = roundCurrency(currentPoint.totals.taxes - previousPoint.totals.taxes) if (taxChangeAnnual > 0) { drivers.push({ @@ -794,7 +851,7 @@ function buildCliffDrivers(previousPoint, currentPoint, metadata) { }) } -function buildSeriesDataFromResponse(payload, metadata, apiResponse, descriptor, seriesMeta) { +export function buildSeriesDataFromResponse(payload, metadata, apiResponse, descriptor, seriesMeta) { const year = String(payload.year) const households = apiResponse?.result?.households?.household || {} const taxUnit = apiResponse?.result?.tax_units?.tax_unit || {} @@ -867,6 +924,10 @@ function buildSeriesDataFromResponse(payload, metadata, apiResponse, descriptor, getYearValue(households, 'household_refundable_state_tax_credits', year), pointCount, ) + const chipPremiumValues = asArray( + getYearValue(households, 'chip_premium', year), + pointCount, + ) const points = earnedIncomeValues.map((earnedIncome, index) => { const programs = { @@ -881,6 +942,9 @@ function buildSeriesDataFromResponse(payload, metadata, apiResponse, descriptor, federal_refundable_credits: roundCurrency(federalRefundableCreditValues[index]), state_refundable_credits: roundCurrency(stateRefundableCreditValues[index]), } + const householdCosts = { + chip_premium: roundCurrency(chipPremiumValues[index]), + } const marketIncome = roundCurrency(marketIncomeValues[index]) const taxes = roundCurrency(taxValues[index]) const stateTaxesBeforeRefundableCredits = roundCurrency(stateTaxValues[index]) @@ -890,7 +954,10 @@ function buildSeriesDataFromResponse(payload, metadata, apiResponse, descriptor, const coreSupport = roundCurrency( Object.values(programs).reduce((sum, value) => sum + value, 0), ) - const netResources = roundCurrency(marketIncome + coreSupport - taxes) + const totalHouseholdCosts = roundCurrency( + Object.values(householdCosts).reduce((sum, value) => sum + value, 0), + ) + const netResources = roundCurrency(marketIncome + coreSupport - taxes - totalHouseholdCosts) return { earned_income: roundCurrency(earnedIncome), step_annual: seriesMeta.effectiveStep, @@ -900,9 +967,11 @@ function buildSeriesDataFromResponse(payload, metadata, apiResponse, descriptor, federal_taxes_before_refundable_credits: federalTaxesBeforeRefundableCredits, state_taxes_before_refundable_credits: stateTaxesBeforeRefundableCredits, core_support: coreSupport, + household_costs: totalHouseholdCosts, net_resources: netResources, }, programs, + household_costs: householdCosts, } }) @@ -933,6 +1002,8 @@ function buildSeriesDataFromResponse(payload, metadata, apiResponse, descriptor, tanf: point.programs.tanf, wic: point.programs.wic, child_care_subsidies: point.programs.child_care_subsidies, + chip_premium: point.household_costs.chip_premium, + household_costs: point.household_costs, has_previous_point: Boolean(previousPoint), cliff_drop_annual: previousPoint && netChangeAnnual < 0 ? roundCurrency(-netChangeAnnual) @@ -1024,4 +1095,3 @@ export async function calculateSeriesViaPolicyEngine(payload, metadata) { seriesMeta, ) } - diff --git a/frontend/src/policyengineApi.test.js b/frontend/src/policyengineApi.test.js new file mode 100644 index 0000000..4889e77 --- /dev/null +++ b/frontend/src/policyengineApi.test.js @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + buildCliffDrivers, + buildHouseholdResultFromResponse, + buildSeriesDataFromResponse, +} from './policyengineApi.js' + +const metadata = { + states: [{ code: 'GA', name: 'Georgia' }], + programs: [], + household_costs: [{ key: 'chip_premium', label: 'CHIP premium' }], +} + +const descriptor = { + id: 'custom_household', + label: '1 adult + 1 dependent', + short_label: '1A/1D', + description: 'Custom household.', + summary: 'Test household.', + counts: { + num_adults: 1, + num_children: 1, + household_size: 2, + }, + people: [ + { + id: 'adult_1', + role: 'head', + kind: 'adult', + age: 34, + is_pregnant: false, + }, + { + id: 'child_1', + role: 'dependent', + kind: 'child', + age: 8, + is_pregnant: false, + }, + ], +} + +function householdResponse() { + return { + result: { + households: { + household: { + household_market_income: { 2026: 30000 }, + household_tax_before_refundable_credits: { 2026: 1000 }, + household_state_tax_before_refundable_credits: { 2026: 300 }, + household_refundable_state_tax_credits: { 2026: 200 }, + chip_premium: { 2026: 600 }, + }, + }, + tax_units: { + tax_unit: { + tax_unit_fpg: { 2026: 20000 }, + income_tax_refundable_credits: { 2026: 400 }, + premium_tax_credit: { '2026-01': 50 }, + }, + }, + spm_units: { + spm_unit: { + snap: { '2026-01': 100 }, + free_school_meals: { 2026: 200 }, + }, + }, + people: { + adult_1: { + wic: { '2026-01': 0 }, + medicaid: { 2026: 0 }, + chip: { 2026: 0 }, + is_aca_ptc_eligible: { 2026: 0 }, + is_medicaid_eligible: { 2026: 0 }, + is_chip_eligible: { 2026: 0 }, + }, + child_1: { + wic: { '2026-01': 0 }, + medicaid: { 2026: 0 }, + chip: { 2026: 5000 }, + is_aca_ptc_eligible: { 2026: 0 }, + is_medicaid_eligible: { 2026: 0 }, + is_chip_eligible: { 2026: 1 }, + }, + }, + }, + } +} + +function seriesResponse() { + return { + result: { + households: { + household: { + household_market_income: { 2026: [1000, 1500] }, + household_tax_before_refundable_credits: { 2026: [0, 0] }, + household_state_tax_before_refundable_credits: { 2026: [0, 0] }, + household_refundable_state_tax_credits: { 2026: [0, 0] }, + chip_premium: { 2026: [0, 1800] }, + }, + }, + tax_units: { + tax_unit: { + income_tax_refundable_credits: { 2026: [0, 0] }, + premium_tax_credit: { '2026-01': [0, 0] }, + }, + }, + spm_units: { + spm_unit: { + snap: { '2026-01': [100, 100] }, + free_school_meals: { 2026: [0, 0] }, + }, + }, + people: { + adult_1: { + employment_income: { 2026: [1000, 1500] }, + wic: { '2026-01': [0, 0] }, + medicaid: { 2026: [0, 0] }, + chip: { 2026: [0, 0] }, + }, + child_1: { + wic: { '2026-01': [0, 0] }, + medicaid: { 2026: [0, 0] }, + chip: { 2026: [2000, 2000] }, + }, + }, + }, + } +} + +test('buildHouseholdResultFromResponse subtracts CHIP premiums from net resources', () => { + const payload = { + state: 'GA', + year: 2026, + earned_income: 30000, + filing_status: 'HEAD_OF_HOUSEHOLD', + } + + const result = buildHouseholdResultFromResponse( + payload, + metadata, + householdResponse(), + descriptor, + ) + + assert.equal(result.household_costs.chip_premium, 600) + assert.equal(result.totals.household_costs, 600) + assert.equal(result.totals.net_resources, 36000) + assert.equal(result.monthly.household_costs, 50) +}) + +test('buildCliffDrivers reports household cost increases as cliff drivers', () => { + const previousPoint = { + programs: {}, + household_costs: { chip_premium: 0 }, + totals: { taxes: 0 }, + } + const currentPoint = { + programs: {}, + household_costs: { chip_premium: 900 }, + totals: { taxes: 0 }, + } + + assert.deepEqual( + buildCliffDrivers(previousPoint, currentPoint, metadata), + [ + { + key: 'chip_premium', + label: 'CHIP premium', + kind: 'household_cost_increase', + raw_change_annual: 900, + raw_change_monthly: 75, + resource_effect_annual: -900, + resource_effect_monthly: -75, + }, + ], + ) +}) + +test('buildSeriesDataFromResponse carries CHIP premiums into series net resources and cliff drivers', () => { + const payload = { + state: 'GA', + year: 2026, + filing_status: 'HEAD_OF_HOUSEHOLD', + } + const seriesMeta = { + pointCount: 2, + effectiveStep: 500, + alignedMaxEarnedIncome: 1500, + } + + const result = buildSeriesDataFromResponse( + payload, + metadata, + seriesResponse(), + descriptor, + seriesMeta, + ) + + assert.equal(result.data[1].chip_premium, 1800) + assert.equal(result.data[1].net_resources, 2900) + assert.equal(result.data[1].cliff_drop_annual, 1300) + assert.equal(result.data[1].cliff_drivers[0].kind, 'household_cost_increase') + assert.equal(result.data[1].cliff_drivers[0].label, 'CHIP premium') +}) diff --git a/frontend/src/utils/cliffReport.js b/frontend/src/utils/cliffReport.js index f0b5926..57ad71f 100644 --- a/frontend/src/utils/cliffReport.js +++ b/frontend/src/utils/cliffReport.js @@ -2,6 +2,7 @@ const round = (value) => Math.round((Number(value) || 0) * 100) / 100 const MIN_REPORTABLE_DROP_ANNUAL = 500 const MIN_REPORTABLE_DRIVER_LOSS_ANNUAL = 500 const ZONE_GAP_MULTIPLIER = 1.5 +const MATERIAL_DRIVER_KINDS = new Set(['benefit_loss', 'household_cost_increase']) const driverImpactAnnual = (driver) => Math.abs(Number(driver?.resource_effect_annual) || 0) const sortDrivers = (drivers) => drivers.sort((left, right) => { @@ -93,7 +94,7 @@ export function rollupCliffDrivers(drivers = []) { export function filterMaterialCliffDrivers(drivers = []) { return sortStepDrivers( drivers.filter((driver) => ( - driver?.kind === 'benefit_loss' + MATERIAL_DRIVER_KINDS.has(driver?.kind) && driverImpactAnnual(driver) >= MIN_REPORTABLE_DRIVER_LOSS_ANNUAL )), ) diff --git a/frontend/src/utils/seriesRefine.js b/frontend/src/utils/seriesRefine.js index 881f428..6e996ed 100644 --- a/frontend/src/utils/seriesRefine.js +++ b/frontend/src/utils/seriesRefine.js @@ -2,6 +2,24 @@ import { buildCliffReport } from './cliffReport' const round = (value) => Math.round((Number(value) || 0) * 100) / 100 const monthly = (value) => round(value / 12) +const DEFAULT_HOUSEHOLD_COST_DEFINITIONS = [ + { + key: 'chip_premium', + label: 'CHIP premium', + }, +] + +function getHouseholdCostDefinitions(metadata) { + const definitions = metadata?.household_costs + if (Array.isArray(definitions) && definitions.length) { + return definitions + } + return DEFAULT_HOUSEHOLD_COST_DEFINITIONS +} + +function getHouseholdCostValue(point, key) { + return Number(point?.household_costs?.[key] ?? point?.[key]) || 0 +} function dedupeSorted(points) { if (!points.length) return points @@ -14,7 +32,7 @@ function dedupeSorted(points) { return out } -function recomputeDeltas(sortedData, programKeys, programLabels) { +function recomputeDeltas(sortedData, programKeys, programLabels, householdCostKeys, householdCostLabels) { return sortedData.map((point, index) => { if (index === 0) { return { @@ -49,6 +67,20 @@ function recomputeDeltas(sortedData, programKeys, programLabels) { }) } }) + householdCostKeys.forEach((key) => { + const change = round(getHouseholdCostValue(point, key) - getHouseholdCostValue(prev, key)) + if (change > 0) { + drivers.push({ + key, + label: householdCostLabels[key] || key, + kind: 'household_cost_increase', + raw_change_annual: change, + raw_change_monthly: monthly(change), + resource_effect_annual: round(-change), + resource_effect_monthly: monthly(-change), + }) + } + }) const taxChange = round(point.taxes - prev.taxes) if (taxChange > 0) { drivers.push({ @@ -95,6 +127,9 @@ export async function refineCliffZones({ const programs = metadata?.programs || [] const programKeys = programs.map((p) => p.key) const programLabels = Object.fromEntries(programs.map((p) => [p.key, p.label])) + const householdCosts = getHouseholdCostDefinitions(metadata) + const householdCostKeys = householdCosts.map((cost) => cost.key) + const householdCostLabels = Object.fromEntries(householdCosts.map((cost) => [cost.key, cost.label])) const refinementJobs = report.zones.map(async (zone) => { const margin = Math.max(coarseStep / 2, refineStep) @@ -136,7 +171,13 @@ export async function refineCliffZones({ .sort((a, b) => a.earned_income - b.earned_income) const deduped = dedupeSorted(mergedRaw) - const withDeltas = recomputeDeltas(deduped, programKeys, programLabels) + const withDeltas = recomputeDeltas( + deduped, + programKeys, + programLabels, + householdCostKeys, + householdCostLabels, + ) return { ...coarseSeries, diff --git a/tests/test_cliff_drivers.py b/tests/test_cliff_drivers.py new file mode 100644 index 0000000..7316c40 --- /dev/null +++ b/tests/test_cliff_drivers.py @@ -0,0 +1,56 @@ +from cliff_watch.calculator import _build_cliff_drivers + + +def test_build_cliff_drivers_includes_household_cost_increases() -> None: + previous = { + "programs": { + "snap": 0.0, + "tanf": 0.0, + "wic": 0.0, + "free_school_meals": 0.0, + "child_care_subsidies": 0.0, + "medicaid": 0.0, + "chip": 0.0, + "aca_ptc": 0.0, + "federal_refundable_credits": 0.0, + "state_refundable_credits": 0.0, + }, + "household_costs": { + "chip_premium": 0.0, + }, + "totals": { + "taxes": 0.0, + }, + } + current = { + "programs": { + "snap": 0.0, + "tanf": 0.0, + "wic": 0.0, + "free_school_meals": 0.0, + "child_care_subsidies": 0.0, + "medicaid": 0.0, + "chip": 0.0, + "aca_ptc": 0.0, + "federal_refundable_credits": 0.0, + "state_refundable_credits": 0.0, + }, + "household_costs": { + "chip_premium": 900.0, + }, + "totals": { + "taxes": 0.0, + }, + } + + assert _build_cliff_drivers(previous, current) == [ + { + "key": "chip_premium", + "label": "CHIP premium", + "kind": "household_cost_increase", + "raw_change_annual": 900.0, + "raw_change_monthly": 75.0, + "resource_effect_annual": -900.0, + "resource_effect_monthly": -75.0, + } + ] diff --git a/tests/test_policyengine_repo_selection.py b/tests/test_policyengine_repo_selection.py new file mode 100644 index 0000000..a9b3b3b --- /dev/null +++ b/tests/test_policyengine_repo_selection.py @@ -0,0 +1,9 @@ +from cliff_watch.calculator import _candidate_policyengine_repo + + +def test_candidate_policyengine_repo_requires_explicit_env_var( + monkeypatch, +) -> None: + monkeypatch.delenv("POLICYENGINE_US_REPO", raising=False) + + assert _candidate_policyengine_repo() is None