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