- Methodology
-
- This analysis uses the PolicyEngine microsimulation model , which{" "}
- reweights {" "}
- the Family Resources Survey to match Scottish demographics. See also:{" "}
- pre-budget dashboard {" "}
- | poverty methodology .
-
+
+
+ Click to see what the Budget includes
+
+
+ {isStacked ? (
+ <>
+ {POLICY_INFO.income_tax_basic_uplift.explanation}
+ {POLICY_INFO.income_tax_intermediate_uplift.explanation}
+ {POLICY_INFO.scp_inflation.explanation}
+ {POLICY_INFO.scp_baby_boost.explanation}
+ {POLICY_INFO.higher_rate_freeze.explanation}
+ {POLICY_INFO.advanced_rate_freeze.explanation}
+ {POLICY_INFO.top_rate_freeze.explanation}
+ >
+ ) : (
+ policyInfo.explanation
+ )}
+
+
+ Methodology
+
+ This analysis uses the PolicyEngine microsimulation model , which{" "}
+ reweights {" "}
+ the Family Resources Survey to match Scottish demographics. See also:{" "}
+ pre-budget dashboard {" "}
+ | poverty methodology .
+
+
+ {/* Income Tax and Benefits Section */}
+ (sectionRefs.current["income-tax-benefits"] = el)} style={{ marginTop: "32px" }}>Income tax and benefits
+
{/* Budgetary Impact Section */}
- (sectionRefs.current["budgetary-impact"] = el)}>Budgetary impact
+ (sectionRefs.current["budgetary-impact"] = el)} style={{ fontSize: "1.4rem", fontWeight: 600, color: "#374151", borderBottom: "none", marginTop: "24px", marginBottom: "12px", padding: "0" }}>Budgetary impact
This section shows the estimated fiscal cost of the budget measures to the Scottish Government.
@@ -600,7 +677,7 @@ export default function Dashboard({ selectedPolicies = [] }) {
{/* Living Standards Section */}
- (sectionRefs.current["living-standards"] = el)}>Living standards
+ (sectionRefs.current["living-standards"] = el)} style={{ fontSize: "1.4rem", fontWeight: 600, color: "#374151", borderBottom: "none", marginTop: "48px", paddingTop: "32px", borderTop: "1px solid #e5e7eb", marginBottom: "12px", padding: "32px 0 0 0" }}>Living standards
This section shows how household incomes in Scotland change as a result of the {policyInfo.name} policy.
@@ -621,11 +698,12 @@ export default function Dashboard({ selectedPolicies = [] }) {
onYearChange={setSelectedYear}
availableYears={AVAILABLE_YEARS}
selectedPolicies={selectedPolicies}
+ fixedYAxisDomain={decileYAxisDomain}
/>
) : null}
{/* Poverty Section */}
- (sectionRefs.current["poverty"] = el)}>Poverty rate
+ (sectionRefs.current["poverty"] = el)} style={{ fontSize: "1.4rem", fontWeight: 600, color: "#374151", borderBottom: "none", marginTop: "48px", paddingTop: "32px", borderTop: "1px solid #e5e7eb", marginBottom: "12px", padding: "32px 0 0 0" }}>Poverty rate
This section shows how poverty rates change under the budget measures.
The UK uses four poverty measures: absolute vs relative poverty, each measured before or after housing costs.
@@ -643,7 +721,7 @@ export default function Dashboard({ selectedPolicies = [] }) {
)}
{/* Local Authority Impact Section */}
-
(sectionRefs.current["local-authorities"] = el)}>Impact by local authority
+ (sectionRefs.current["local-authorities"] = el)} style={{ fontSize: "1.4rem", fontWeight: 600, color: "#374151", borderBottom: "none", marginTop: "48px", paddingTop: "32px", borderTop: "1px solid #e5e7eb", marginBottom: "12px", padding: "32px 0 0 0" }}>Impact by local authority
This section shows how the budget measures affect different local authorities across Scotland.
Select a local authority to see the estimated impact on households in that area.
@@ -657,15 +735,16 @@ export default function Dashboard({ selectedPolicies = [] }) {
/>
{/* Mansion Tax Section */}
- (sectionRefs.current["mansion-tax"] = el)}>
-
- ▶
+ (sectionRefs.current["mansion-tax"] = el)} style={{ marginTop: "32px" }}>
+
+ ▶
Mansion tax
- The Scottish Budget 2026-27 introduced new council tax bands for properties valued at £1 million or more,
- effective from April 2028. The Finance Secretary estimated £16m in annual revenue; using UK benchmark rates, we estimate £18.5m.
- The map below shows each constituency's share. Edinburgh constituencies account for ~47% of total revenue.
+ The Scottish Budget 2026-27 introduced two new council tax bands for properties with a 2026 market value above £1 million,
+ effective from April 2028. The Finance Secretary estimated £16m in annual revenue.
+ The SFC does not cost this policy as Council Tax is a local tax outside their remit.
+ Using UK benchmark rates, we estimate £18.5m in annual revenue. The map below shows each constituency's share. Edinburgh constituencies account for ~47% of total revenue.
How we calculate
@@ -679,23 +758,19 @@ export default function Dashboard({ selectedPolicies = [] }) {
1.
-
We estimate total revenue by multiplying 11,481 £1m+ properties (from Savills ) by the £1,607 average annual rate based on UK benchmark rates.
+
We estimate total revenue of £18.5m by multiplying 11,481 £1m+ properties in Scotland (from Savills ) by a £1,607 average annual rate. This rate is based on the UK's High Value Council Tax Surcharge of £2,500/year for properties over £2m, from which we extrapolate £1,500/year for the Scottish £1-2m band. Using Savills 2024 sales data (89% of sales £1-2m, 11% over £2m), we calculate the weighted average of £1,607/year.
2.
-
We use council-level £1m+ sales data from Registers of Scotland to determine geographic distribution across Scotland.
+
We use council-level £1m+ sales data from Registers of Scotland to allocate revenue geographically across Scotland.
3.
-
Within each council, we allocate sales to constituencies based on population weighted by Band H property concentration .
+
Within each council, we allocate to constituencies based on population weighted by Band H property concentration . Band H threshold (>£212k in 1991) equals ~£1.06m today, closely matching the £1m threshold.
4.
- We use Band H as a proxy because its threshold (>£212k in 1991) equals approximately £1.06m today , closely matching the mansion tax's £1m threshold.
-
-
- 5.
- Each constituency's revenue is calculated by multiplying its share of total sales by the £18.5m total revenue .
+ Each constituency's revenue is calculated as its share of total sales multiplied by the £18.5m total revenue.
diff --git a/src/components/DecileChart.jsx b/src/components/DecileChart.jsx
index 87c4f95..3498508 100644
--- a/src/components/DecileChart.jsx
+++ b/src/components/DecileChart.jsx
@@ -66,6 +66,7 @@ export default function DecileChart({
onYearChange = null,
availableYears = [2026, 2027, 2028, 2029, 2030],
selectedPolicies = [],
+ fixedYAxisDomain = null,
}) {
const [viewMode, setViewMode] = useState("absolute"); // "absolute" or "relative"
const formatYearRange = (year) => `${year}-${(year + 1).toString().slice(-2)}`;
@@ -148,37 +149,64 @@ export default function DecileChart({
// Show net change line when multiple policies are active
const showNetChange = stacked && activePolicies.length > 1;
- // Calculate y-axis domain with padding
- let yMin = 0, yMax = 10;
- if (stacked) {
+ // Calculate symmetric y-axis domain with round number increments
+ const calculateSymmetricDomain = () => {
let minSum = 0, maxSum = 0;
- chartData.forEach(d => {
- let positiveSum = 0, negativeSum = 0;
- activePolicies.forEach(name => {
- const val = d[name] || 0;
- if (val > 0) positiveSum += val;
- else negativeSum += val;
+ if (stacked) {
+ chartData.forEach(d => {
+ let positiveSum = 0, negativeSum = 0;
+ activePolicies.forEach(name => {
+ const val = d[name] || 0;
+ if (val > 0) positiveSum += val;
+ else negativeSum += val;
+ });
+ minSum = Math.min(minSum, negativeSum);
+ maxSum = Math.max(maxSum, positiveSum);
});
- minSum = Math.min(minSum, negativeSum);
- maxSum = Math.max(maxSum, positiveSum);
- });
- // Add 15% padding
- const padding = Math.max(Math.abs(minSum), Math.abs(maxSum)) * 0.15;
+ } else {
+ const values = chartData.map(d => d.value || 0);
+ maxSum = Math.max(...values, 0);
+ minSum = Math.min(...values, 0);
+ }
+
+ // Find the max absolute value and round up to nice number for symmetric axis
+ const maxAbs = Math.max(Math.abs(minSum), Math.abs(maxSum));
+
if (viewMode === "relative") {
- yMin = Math.floor((minSum - padding) * 10) / 10;
- yMax = Math.ceil((maxSum + padding) * 10) / 10;
+ // For percentages: use intervals of 0.5, 1, or 2
+ const interval = maxAbs <= 1 ? 0.5 : maxAbs <= 3 ? 1 : 2;
+ const roundedMax = Math.ceil((maxAbs * 1.1) / interval) * interval;
+ return [-roundedMax, roundedMax];
} else {
- yMin = Math.floor((minSum - padding) / 10) * 10;
- yMax = Math.ceil((maxSum + padding) / 10) * 10 || 40;
+ // For absolute £: use intervals of 10, 20, or 50
+ const interval = maxAbs <= 50 ? 10 : maxAbs <= 100 ? 20 : 50;
+ const roundedMax = Math.ceil((maxAbs * 1.1) / interval) * interval || 40;
+ return [-roundedMax, roundedMax];
}
- } else {
- const values = chartData.map(d => d.value || 0);
- const maxVal = Math.max(...values);
- yMin = 0;
- yMax = viewMode === "relative"
- ? Math.ceil(maxVal * 10) / 10
- : Math.max(40, Math.ceil(maxVal / 10) * 10);
- }
+ };
+
+ // Generate symmetric ticks including 0
+ const generateTicks = (domain) => {
+ const [min, max] = domain;
+ const range = max - min;
+ let interval;
+ if (viewMode === "relative") {
+ interval = range <= 2 ? 0.5 : range <= 6 ? 1 : 2;
+ } else {
+ interval = range <= 100 ? 10 : range <= 200 ? 20 : 50;
+ }
+ const ticks = [];
+ for (let i = min; i <= max + 0.001; i += interval) {
+ ticks.push(Math.round(i * 100) / 100); // Avoid floating point issues
+ }
+ return ticks;
+ };
+
+ // Use fixed domain if provided (for consistent axis across years), otherwise calculate
+ const [yMin, yMax] = fixedYAxisDomain
+ ? (viewMode === "relative" ? fixedYAxisDomain.relative : fixedYAxisDomain.absolute)
+ : calculateSymmetricDomain();
+ const yTicks = generateTicks([yMin, yMax]);
return (
@@ -239,9 +267,9 @@ export default function DecileChart({
/>
[
formatValue(value),
- name === "netChange" ? "Net change" : name
+ name === "Net change" ? "Net change" : name
]}
labelFormatter={(label) => `${label} decile`}
contentStyle={{
@@ -314,7 +342,7 @@ export default function DecileChart({
stroke="#000000"
strokeWidth={2}
dot={{ fill: "#000000", stroke: "#000000", strokeWidth: 1, r: 4 }}
- name="netChange"
+ name="Net change"
/>
)}
diff --git a/src/components/HouseholdCalculator.css b/src/components/HouseholdCalculator.css
index 72699bd..3143ec8 100644
--- a/src/components/HouseholdCalculator.css
+++ b/src/components/HouseholdCalculator.css
@@ -67,6 +67,28 @@
margin-bottom: 8px;
}
+/* Year select dropdown */
+.year-select {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ color: #1e293b;
+ background: white;
+ cursor: pointer;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+}
+
+.year-select:focus {
+ outline: none;
+ border-color: #319795;
+ box-shadow: 0 0 0 3px rgba(49, 151, 149, 0.1);
+}
+
.input-group > .help-text {
display: block;
margin-top: 6px;
@@ -158,7 +180,7 @@
}
.age-input {
- width: 70px;
+ flex: 1;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
@@ -177,6 +199,7 @@
}
.add-btn {
+ flex: 1;
padding: 10px 16px;
background: #319795;
color: white;
diff --git a/src/components/HouseholdCalculator.jsx b/src/components/HouseholdCalculator.jsx
index 08237c9..78e401c 100644
--- a/src/components/HouseholdCalculator.jsx
+++ b/src/components/HouseholdCalculator.jsx
@@ -21,16 +21,18 @@ const DEFAULT_INPUTS = {
receives_uc: true, // UC or other qualifying benefit for SCP
};
-// Chart colors matching REFORMS
+// Chart colors matching budget impact chart
+// Teal = costs to government (good for households)
+// Amber = revenue raisers (bad for households)
const CHART_COLORS = {
total: "#0F766E", // Teal 700
income_tax_basic_uplift: "#0D9488", // Teal 600
- income_tax_intermediate_uplift: "#14B8A6", // Teal 500
- higher_rate_freeze: "#F97316", // Orange 500
- advanced_rate_freeze: "#FB923C", // Orange 400
- top_rate_freeze: "#FDBA74", // Orange 300
- scp_inflation: "#2DD4BF", // Teal 400
- scp_baby_boost: "#5EEAD4", // Teal 300
+ income_tax_intermediate_uplift: "#0F766E", // Teal 700
+ scp_inflation: "#14B8A6", // Teal 500
+ scp_baby_boost: "#2DD4BF", // Teal 400
+ higher_rate_freeze: "#78350F", // Amber 900 (darkest)
+ advanced_rate_freeze: "#92400E", // Amber 800
+ top_rate_freeze: "#B45309", // Amber 700
};
// Slider configurations
@@ -51,7 +53,7 @@ function HouseholdCalculator() {
const [childAgeInput, setChildAgeInput] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
- const [selectedYear, setSelectedYear] = useState(2027);
+ const [selectedYear, setSelectedYear] = useState(2026);
const [showRealTerms, setShowRealTerms] = useState(false);
const [impacts, setImpacts] = useState({
income_tax_basic_uplift: 0,
@@ -64,8 +66,11 @@ function HouseholdCalculator() {
total: 0,
});
const [yearlyData, setYearlyData] = useState([]);
+ const [byIncomeData, setByIncomeData] = useState([]);
const yearlyChartRef = useRef(null);
const yearlyChartContainerRef = useRef(null);
+ const incomeChartRef = useRef(null);
+ const incomeChartContainerRef = useRef(null);
const years = [2026, 2027, 2028, 2029, 2030];
@@ -118,13 +123,18 @@ function HouseholdCalculator() {
}));
}, []);
- // Combined calculate function - single API request for all data
+ // State for loading income chart separately
+ const [loadingIncomeChart, setLoadingIncomeChart] = useState(false);
+
+ // Calculate function - uses single year endpoint for faster response
const calculateAll = useCallback(async () => {
setLoading(true);
setError(null);
+ setByIncomeData([]); // Clear previous income data
try {
- const response = await fetch(`${API_BASE_URL}/calculate-all`, {
+ // First, get single year data (faster ~26s instead of ~2min)
+ const response = await fetch(`${API_BASE_URL}/calculate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...inputs, year: selectedYear }),
@@ -135,49 +145,64 @@ function HouseholdCalculator() {
throw new Error(result.error);
}
- // Process yearly data
- setYearlyData(result.yearly);
-
- // Set current year impacts
- const currentYearData = result.yearly.find((d) => d.year === selectedYear);
- if (currentYearData) {
- setImpacts({
- income_tax_basic_uplift: currentYearData.income_tax_basic_uplift,
- income_tax_intermediate_uplift: currentYearData.income_tax_intermediate_uplift,
- higher_rate_freeze: currentYearData.higher_rate_freeze,
- advanced_rate_freeze: currentYearData.advanced_rate_freeze,
- top_rate_freeze: currentYearData.top_rate_freeze,
- scp_inflation: currentYearData.scp_inflation,
- scp_baby_boost: currentYearData.scp_baby_boost,
- total: currentYearData.total,
- });
+ // Set impacts from single year response
+ setImpacts({
+ income_tax_basic_uplift: result.impacts.income_tax_basic_uplift,
+ income_tax_intermediate_uplift: result.impacts.income_tax_intermediate_uplift,
+ higher_rate_freeze: result.impacts.higher_rate_freeze,
+ advanced_rate_freeze: result.impacts.advanced_rate_freeze,
+ top_rate_freeze: result.impacts.top_rate_freeze,
+ scp_inflation: result.impacts.scp_inflation,
+ scp_baby_boost: result.impacts.scp_baby_boost,
+ total: result.total,
+ });
+
+ // Create yearly data from single year (for now, just show selected year)
+ setYearlyData([{
+ year: selectedYear,
+ ...result.impacts,
+ total: result.total,
+ }]);
+
+ setLoading(false);
+
+ // Then fetch by_income data in background
+ setLoadingIncomeChart(true);
+ const incomeResponse = await fetch(`${API_BASE_URL}/calculate-by-income`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...inputs, year: selectedYear }),
+ });
+ const incomeResult = await incomeResponse.json();
+
+ if (incomeResult.by_income) {
+ setByIncomeData(incomeResult.by_income);
}
} catch (err) {
console.error("Error calculating:", err);
setError(err.message);
} finally {
setLoading(false);
+ setLoadingIncomeChart(false);
}
}, [inputs, selectedYear]);
- // Update impacts when year changes
+ // Track if we've calculated before (to know when to auto-recalculate on year change)
+ const hasCalculated = useRef(false);
+
+ // Update hasCalculated when we get results
useEffect(() => {
if (yearlyData.length > 0) {
- const currentYearData = yearlyData.find((d) => d.year === selectedYear);
- if (currentYearData) {
- setImpacts({
- income_tax_basic_uplift: currentYearData.income_tax_basic_uplift,
- income_tax_intermediate_uplift: currentYearData.income_tax_intermediate_uplift,
- higher_rate_freeze: currentYearData.higher_rate_freeze,
- advanced_rate_freeze: currentYearData.advanced_rate_freeze,
- top_rate_freeze: currentYearData.top_rate_freeze,
- scp_inflation: currentYearData.scp_inflation,
- scp_baby_boost: currentYearData.scp_baby_boost,
- total: currentYearData.total,
- });
- }
+ hasCalculated.current = true;
+ }
+ }, [yearlyData]);
+
+ // Re-run calculation when year changes (if we've already calculated once)
+ useEffect(() => {
+ if (hasCalculated.current && !loading) {
+ calculateAll();
}
- }, [selectedYear, yearlyData]);
+ }, [selectedYear]); // Only trigger on year change, not on calculateAll change
// Draw yearly projection chart
useEffect(() => {
@@ -228,10 +253,21 @@ function HouseholdCalculator() {
.range([0, width])
.padding(0.3);
- // Dynamic Y scale based on actual data values (handle both positive and negative)
- const allTotals = processedData.map((d) => d.total);
- const dataMax = Math.max(...allTotals);
- const dataMin = Math.min(...allTotals);
+ // Dynamic Y scale based on stacked values (sum positives and negatives separately)
+ const policyKeysForScale = ['income_tax_basic_uplift', 'income_tax_intermediate_uplift', 'higher_rate_freeze', 'advanced_rate_freeze', 'top_rate_freeze', 'scp_inflation', 'scp_baby_boost'];
+ let dataMax = 0;
+ let dataMin = 0;
+ processedData.forEach((d) => {
+ let posSum = 0;
+ let negSum = 0;
+ policyKeysForScale.forEach((key) => {
+ const val = d[key] || 0;
+ if (val > 0) posSum += val;
+ else negSum += val;
+ });
+ dataMax = Math.max(dataMax, posSum);
+ dataMin = Math.min(dataMin, negSum);
+ });
const yMax = dataMax > 0 ? dataMax * 1.2 : 10;
const yMin = dataMin < 0 ? dataMin * 1.2 : 0;
const y = d3.scaleLinear().domain([yMin, yMax]).range([height, 0]).nice();
@@ -291,31 +327,63 @@ function HouseholdCalculator() {
.attr("fill", "#6B7280")
.attr("font-size", "11px");
- // Bars - simple total bar (handles positive and negative)
+ // Stacked bars - draw each policy component
const zeroY = y(0);
+ const policyKeys = [
+ { key: 'income_tax_basic_uplift', color: CHART_COLORS.income_tax_basic_uplift },
+ { key: 'income_tax_intermediate_uplift', color: CHART_COLORS.income_tax_intermediate_uplift },
+ { key: 'higher_rate_freeze', color: CHART_COLORS.higher_rate_freeze },
+ { key: 'advanced_rate_freeze', color: CHART_COLORS.advanced_rate_freeze },
+ { key: 'top_rate_freeze', color: CHART_COLORS.top_rate_freeze },
+ { key: 'scp_inflation', color: CHART_COLORS.scp_inflation },
+ { key: 'scp_baby_boost', color: CHART_COLORS.scp_baby_boost },
+ ];
+
processedData.forEach((d) => {
- const barY = d.total >= 0 ? y(d.total) : zeroY;
- const barHeight = Math.abs(y(d.total) - zeroY);
+ let posOffset = 0; // Tracks cumulative positive stack position
+ let negOffset = 0; // Tracks cumulative negative stack position
- // Draw bar from zero line
- if (barHeight > 0) {
- g.append("rect")
- .attr("class", `bar-${d.year}`)
- .attr("x", x(d.year))
- .attr("y", barY)
- .attr("width", x.bandwidth())
- .attr("height", barHeight)
- .attr("fill", d.total >= 0 ? CHART_COLORS.income_tax_basic_uplift : "#F97316")
- .attr("rx", 2);
- }
+ policyKeys.forEach(({ key, color }) => {
+ const value = d[key] || 0;
+ if (Math.abs(value) < 0.01) return; // Skip zero values
+
+ if (value >= 0) {
+ // Positive values stack upward from zero
+ const barHeight = zeroY - y(value);
+ g.append("rect")
+ .attr("x", x(d.year))
+ .attr("y", zeroY - posOffset - barHeight)
+ .attr("width", x.bandwidth())
+ .attr("height", barHeight)
+ .attr("fill", color);
+ posOffset += barHeight;
+ } else {
+ // Negative values stack downward from zero
+ const barHeight = y(value) - zeroY;
+ g.append("rect")
+ .attr("x", x(d.year))
+ .attr("y", zeroY + negOffset)
+ .attr("width", x.bandwidth())
+ .attr("height", barHeight)
+ .attr("fill", color);
+ negOffset += barHeight;
+ }
+ });
+
+ // Calculate total bar bounds for highlight
+ const totalPosHeight = posOffset;
+ const totalNegHeight = negOffset;
+ const barTop = zeroY - totalPosHeight;
+ const barBottom = zeroY + totalNegHeight;
+ const totalBarHeight = barBottom - barTop;
// Highlight selected year
- if (d.year === selectedYear) {
+ if (d.year === selectedYear && totalBarHeight > 0) {
g.append("rect")
.attr("x", x(d.year) - 2)
- .attr("y", barY - 2)
+ .attr("y", barTop - 2)
.attr("width", x.bandwidth() + 4)
- .attr("height", barHeight + 4)
+ .attr("height", totalBarHeight + 4)
.attr("fill", "none")
.attr("stroke", CHART_COLORS.total)
.attr("stroke-width", 2)
@@ -395,27 +463,27 @@ function HouseholdCalculator() {
${formatVal(d.income_tax_basic_uplift)}
- Intermediate uplift
+ Intermediate uplift
${formatVal(d.income_tax_intermediate_uplift)}
- Higher freeze
+ Higher freeze
${formatVal(d.higher_rate_freeze)}
- Advanced freeze
+ Advanced freeze
${formatVal(d.advanced_rate_freeze)}
- Top rate freeze
+ Top rate freeze
${formatVal(d.top_rate_freeze)}
- SCP inflation
+ SCP inflation
${formatVal(d.scp_inflation)}
- SCP baby boost
+ SCP baby boost
${formatVal(d.scp_baby_boost)}
@@ -436,6 +504,256 @@ function HouseholdCalculator() {
};
}, [yearlyData, selectedYear, showRealTerms, toRealTerms]);
+ // Draw income level chart
+ useEffect(() => {
+ if (
+ !byIncomeData.length ||
+ !incomeChartRef.current ||
+ !incomeChartContainerRef.current
+ )
+ return;
+
+ const svg = d3.select(incomeChartRef.current);
+ svg.selectAll("*").remove();
+
+ const containerWidth = incomeChartContainerRef.current.clientWidth;
+ const margin = { top: 20, right: 24, bottom: 50, left: 70 };
+ const width = containerWidth - margin.left - margin.right;
+ const height = 220 - margin.top - margin.bottom;
+
+ svg.attr("width", containerWidth).attr("height", 220);
+
+ const g = svg
+ .append("g")
+ .attr("transform", `translate(${margin.left},${margin.top})`);
+
+ // Scales
+ const x = d3
+ .scaleLinear()
+ .domain([0, 200000])
+ .range([0, width]);
+
+ const yMin = d3.min(byIncomeData, (d) => d.total);
+ const yMax = d3.max(byIncomeData, (d) => d.total);
+ const yPadding = Math.max(Math.abs(yMin), Math.abs(yMax)) * 0.1;
+ const y = d3
+ .scaleLinear()
+ .domain([Math.min(yMin - yPadding, 0), Math.max(yMax + yPadding, 0)])
+ .range([height, 0])
+ .nice();
+
+ // Grid lines
+ g.append("g")
+ .attr("class", "grid-lines")
+ .selectAll("line")
+ .data(y.ticks(5))
+ .enter()
+ .append("line")
+ .attr("x1", 0)
+ .attr("x2", width)
+ .attr("y1", (d) => y(d))
+ .attr("y2", (d) => y(d))
+ .attr("stroke", "#E2E8F0")
+ .attr("stroke-dasharray", "2,2");
+
+ // Zero line
+ g.append("line")
+ .attr("x1", 0)
+ .attr("x2", width)
+ .attr("y1", y(0))
+ .attr("y2", y(0))
+ .attr("stroke", "#94a3b8")
+ .attr("stroke-width", 1);
+
+ // X axis
+ g.append("g")
+ .attr("transform", `translate(0,${height})`)
+ .call(
+ d3
+ .axisBottom(x)
+ .ticks(5)
+ .tickFormat((d) => `£${d / 1000}k`)
+ .tickSize(0)
+ .tickPadding(10)
+ )
+ .call((g) => g.select(".domain").attr("stroke", "#D1D5DB"))
+ .selectAll("text")
+ .attr("fill", "#6B7280")
+ .attr("font-size", "11px");
+
+ // X axis label
+ g.append("text")
+ .attr("x", width / 2)
+ .attr("y", height + 40)
+ .attr("text-anchor", "middle")
+ .attr("fill", "#6B7280")
+ .attr("font-size", "12px")
+ .text("Employment income");
+
+ // Y axis
+ g.append("g")
+ .call(
+ d3
+ .axisLeft(y)
+ .ticks(5)
+ .tickFormat((d) => `£${d}`)
+ .tickSize(0)
+ .tickPadding(10)
+ )
+ .call((g) => g.select(".domain").remove())
+ .selectAll("text")
+ .attr("fill", "#6B7280")
+ .attr("font-size", "11px");
+
+ // Area fill
+ const area = d3
+ .area()
+ .x((d) => x(d.income))
+ .y0(y(0))
+ .y1((d) => y(d.total))
+ .curve(d3.curveMonotoneX);
+
+ // Split data into positive and negative areas
+ const positiveData = byIncomeData.map((d) => ({
+ income: d.income,
+ total: Math.max(0, d.total),
+ }));
+ const negativeData = byIncomeData.map((d) => ({
+ income: d.income,
+ total: Math.min(0, d.total),
+ }));
+
+ // Positive area (teal)
+ g.append("path")
+ .datum(positiveData)
+ .attr("fill", "rgba(13, 148, 136, 0.2)")
+ .attr("d", area);
+
+ // Negative area (amber)
+ g.append("path")
+ .datum(negativeData)
+ .attr("fill", "rgba(180, 83, 9, 0.2)")
+ .attr("d", area);
+
+ // Line
+ const line = d3
+ .line()
+ .x((d) => x(d.income))
+ .y((d) => y(d.total))
+ .curve(d3.curveMonotoneX);
+
+ g.append("path")
+ .datum(byIncomeData)
+ .attr("fill", "none")
+ .attr("stroke", CHART_COLORS.total)
+ .attr("stroke-width", 2)
+ .attr("d", line);
+
+
+ // Vertical hover line
+ const hoverLine = g.append("line")
+ .attr("class", "hover-line")
+ .attr("y1", 0)
+ .attr("y2", height)
+ .attr("stroke", "#94a3b8")
+ .attr("stroke-width", 1)
+ .attr("stroke-dasharray", "4,4")
+ .style("opacity", 0)
+ .style("pointer-events", "none");
+
+ // Tooltip
+ d3.select(incomeChartContainerRef.current).style("position", "relative");
+ const tooltip = d3
+ .select(incomeChartContainerRef.current)
+ .append("div")
+ .attr("class", "income-chart-tooltip")
+ .style("position", "absolute")
+ .style("background", "white")
+ .style("border", "1px solid #e2e8f0")
+ .style("border-radius", "8px")
+ .style("padding", "10px")
+ .style("font-size", "11px")
+ .style("box-shadow", "0 4px 12px rgba(0,0,0,0.1)")
+ .style("pointer-events", "none")
+ .style("opacity", 0)
+ .style("z-index", 10);
+
+ // Hover area
+ g.append("rect")
+ .attr("width", width)
+ .attr("height", height)
+ .attr("fill", "transparent")
+ .on("mousemove", (event) => {
+ const [mouseX] = d3.pointer(event);
+ const income = x.invert(mouseX);
+ const closest = byIncomeData.reduce((prev, curr) =>
+ Math.abs(curr.income - income) < Math.abs(prev.income - income) ? curr : prev
+ );
+
+ // Update vertical line position
+ hoverLine
+ .attr("x1", x(closest.income))
+ .attr("x2", x(closest.income))
+ .style("opacity", 1);
+
+ const formatVal = (v) => {
+ if (Math.abs(v) < 0.01) return "£0";
+ const sign = v < 0 ? "-" : "+";
+ return `${sign}£${Math.abs(v).toFixed(0)}`;
+ };
+
+ tooltip
+ .html(`
+
+ £${closest.income.toLocaleString()} income
+
+
+ Basic rate uplift
+ ${formatVal(closest.income_tax_basic_uplift)}
+
+
+ Intermediate uplift
+ ${formatVal(closest.income_tax_intermediate_uplift)}
+
+
+ Higher freeze
+ ${formatVal(closest.higher_rate_freeze)}
+
+
+ Advanced freeze
+ ${formatVal(closest.advanced_rate_freeze)}
+
+
+ Top rate freeze
+ ${formatVal(closest.top_rate_freeze)}
+
+
+ SCP inflation
+ ${formatVal(closest.scp_inflation)}
+
+
+ SCP baby boost
+ ${formatVal(closest.scp_baby_boost)}
+
+
+ Total
+ ${formatVal(closest.total)}
+
+ `)
+ .style("opacity", 1)
+ .style("left", `${x(closest.income) + margin.left - 200}px`)
+ .style("top", `${margin.top + 10}px`);
+ })
+ .on("mouseout", () => {
+ tooltip.style("opacity", 0);
+ hoverLine.style("opacity", 0);
+ });
+
+ return () => {
+ tooltip.remove();
+ };
+ }, [byIncomeData, inputs.employment_income]);
+
// Format currency
const formatCurrency = useCallback(
(value, showSign = true) => {
@@ -469,6 +787,22 @@ function HouseholdCalculator() {
Household details
+ {/* Year selector dropdown */}
+
+ Year
+ setSelectedYear(parseInt(e.target.value))}
+ className="year-select"
+ >
+ {years.map((year) => (
+
+ {year}-{(year + 1).toString().slice(-2)}
+
+ ))}
+
+
+
{/* Employment income slider */}
{SLIDER_CONFIGS.map((config) => (
@@ -534,23 +868,6 @@ function HouseholdCalculator() {
)}
- {/* UC eligibility */}
-
-
-
- handleInputChange("receives_uc", e.target.checked)
- }
- />
- Receives Universal Credit
-
-
- Required for Scottish Child Payment
-
-
-
{/* Children */}
Children
@@ -609,22 +926,8 @@ function HouseholdCalculator() {
{/* Results */}
- {/* Year selector and real terms toggle */}
+ {/* Real terms toggle */}
-
-
Year:
-
- {years.map((year) => (
- setSelectedYear(year)}
- >
- {year}-{(year + 1).toString().slice(-2)}
-
- ))}
-
-
Error: {error}. Please try again.
)}
- {/* Total impact card */}
- {!loading && (
+ {/* Prompt to calculate - shown when no calculation has been done */}
+ {!loading && yearlyData.length === 0 && (
+
+
+ Enter household details and click Calculate to see the impacts
+
+
+ )}
+
+ {/* Total impact card - shown after calculation */}
+ {!loading && yearlyData.length > 0 && (
0 ? "positive" : impacts.total < 0 ? "negative" : "neutral"}`}
>
@@ -669,8 +981,8 @@ function HouseholdCalculator() {
)}
- {/* Breakdown by reform */}
- {!loading && (
+ {/* Breakdown by reform - only shown after calculation */}
+ {!loading && yearlyData.length > 0 && (
Breakdown by policy
{REFORMS.map((reform) => {
@@ -697,19 +1009,27 @@ function HouseholdCalculator() {
)}
- {/* Yearly projection chart */}
- {!loading && yearlyData.length > 0 && (
+ {/* Impact by income level chart */}
+ {!loading && (loadingIncomeChart || byIncomeData.length > 0) && (
-
Impact over time
+
Impact by income level
- Click a year to see detailed breakdown
+ How the budget affects households at different income levels in {selectedYear}-{(selectedYear + 1).toString().slice(-2)}
-
-
-
+ {loadingIncomeChart && byIncomeData.length === 0 ? (
+
+
+
Loading income chart...
+
+ ) : (
+
+
+
+ )}
)}
diff --git a/src/components/LocalAreaSection.jsx b/src/components/LocalAreaSection.jsx
index 3efac65..cfcbe81 100644
--- a/src/components/LocalAreaSection.jsx
+++ b/src/components/LocalAreaSection.jsx
@@ -152,6 +152,50 @@ export default function LocalAreaSection({
}));
}, [localAuthorityData]);
+ // Calculate fixed color extent across ALL years for consistent map coloring
+ const fixedColorExtent = useMemo(() => {
+ if (!rawLocalAuthorityData.length || !selectedPolicies.length) return null;
+
+ let globalMin = Infinity;
+ let globalMax = -Infinity;
+
+ // Check all years
+ availableYears.forEach(year => {
+ // Group by local authority for this year
+ const laMap = new Map();
+
+ rawLocalAuthorityData.forEach((row) => {
+ if (!selectedPolicies.includes(row.reform_id)) return;
+ if (row.year !== year) return;
+
+ const key = row.code;
+ if (!laMap.has(key)) {
+ laMap.set(key, 0);
+ }
+ laMap.set(key, laMap.get(key) + row.avgGain);
+ });
+
+ // Find min/max for this year
+ laMap.forEach(value => {
+ globalMin = Math.min(globalMin, value);
+ globalMax = Math.max(globalMax, value);
+ });
+ });
+
+ if (globalMin === Infinity || globalMax === -Infinity) return null;
+
+ // Determine type
+ let type = 'mixed';
+ if (globalMin >= 0) type = 'positive';
+ else if (globalMax <= 0) type = 'negative';
+
+ return {
+ min: Math.floor(globalMin),
+ max: Math.ceil(globalMax),
+ type
+ };
+ }, [rawLocalAuthorityData, selectedPolicies, availableYears]);
+
// Handle local authority selection from map
const handleLocalAuthoritySelect = (laData) => {
if (laData) {
@@ -219,6 +263,7 @@ export default function LocalAreaSection({
onLocalAuthoritySelect={handleLocalAuthoritySelect}
policyName={policyName}
selectedPolicies={selectedPolicies}
+ fixedColorExtent={fixedColorExtent}
/>
diff --git a/src/components/SFCComparisonTable.css b/src/components/SFCComparisonTable.css
index 1dc4d44..e923a72 100644
--- a/src/components/SFCComparisonTable.css
+++ b/src/components/SFCComparisonTable.css
@@ -5,7 +5,7 @@
}
.sfc-comparison-section h2 {
- font-size: 1.25rem;
+ font-size: 1.4rem;
font-weight: 600;
color: #374151;
margin: 0 0 12px 0;
diff --git a/src/components/SFCComparisonTable.jsx b/src/components/SFCComparisonTable.jsx
index 180736a..dddb9f6 100644
--- a/src/components/SFCComparisonTable.jsx
+++ b/src/components/SFCComparisonTable.jsx
@@ -139,7 +139,7 @@ function SFCComparisonTable() {
return (
-
PolicyEngine vs SFC comparison
+
PolicyEngine vs SFC comparison
This table compares PolicyEngine's static microsimulation estimates with
the Scottish Fiscal Commission's official costings from the January 2026
@@ -234,38 +234,44 @@ function SFCComparisonTable() {
-
- Note: PolicyEngine produces static microsimulation
- estimates that do not include behavioural responses.{" "}
- {showBehavioural
- ? "Post-behavioural costings include effects like tax avoidance, reduced consumption, and migration. SFC assumes behavioural responses reduce yields by ~8% for higher-rate freezes, ~25% for advanced-rate, and ~85% for top-rate."
- : "Static costings assume no change in taxpayer behaviour. Static values shown here are derived from post-behavioural figures using SFC's published behavioural adjustment rates."}{" "}
- Each provision is costed independently against baseline (not stacked).
-
-
- Data notes: Threshold freezes show 2026-27 as empty because
- the freeze was already in Budget 2025-26; SFC only costs the incremental
- extension through 2027-28/2028-29. SCP baby boost starts mid-2027-28.
- SFC reports combined basic + intermediate thresholds (~£50m total); we
- apportion using PolicyEngine microsimulation. SCP inflation uprating has
- no SFC costing as it's included in their baseline. See{" "}
-
- SFC January 2026 Forecasts
- {" "}
- and{" "}
-
- IFS analysis
- {" "}
- for methodology.
-
+
+ Note
+
+ PolicyEngine produces static microsimulation
+ estimates that do not include behavioural responses.{" "}
+ {showBehavioural
+ ? "Post-behavioural costings include effects like tax avoidance, reduced consumption, and migration. SFC assumes behavioural responses reduce yields by ~8% for higher-rate freezes, ~25% for advanced-rate, and ~85% for top-rate."
+ : "Static costings assume no change in taxpayer behaviour. Static values shown here are derived from post-behavioural figures using SFC's published behavioural adjustment rates."}{" "}
+ Each provision is costed independently against baseline (not stacked).
+
+
+
+ Data notes
+
+ Threshold freezes show 2026-27 as empty because
+ the freeze was already in Budget 2025-26; SFC only costs the incremental
+ extension through 2027-28/2028-29. SCP baby boost starts mid-2027-28.
+ SFC reports combined basic + intermediate thresholds (~£50m total); we
+ apportion using PolicyEngine microsimulation. SCP inflation uprating has
+ no SFC costing as it's included in their baseline. See{" "}
+
+ SFC January 2026 Forecasts
+ {" "}
+ and{" "}
+
+ IFS analysis
+ {" "}
+ for methodology.
+
+
);
}
diff --git a/src/components/ScotlandMap.jsx b/src/components/ScotlandMap.jsx
index a887121..8256017 100644
--- a/src/components/ScotlandMap.jsx
+++ b/src/components/ScotlandMap.jsx
@@ -10,8 +10,11 @@ const formatYearRange = (year) => `${year}-${(year + 1).toString().slice(-2)}`;
const POLICY_DISPLAY_NAMES = {
scp_baby_boost: "SCP Premium for under-ones",
scp_inflation: "SCP inflation adjustment",
- income_tax_basic_uplift: "Basic rate +7.4%",
- income_tax_intermediate_uplift: "Intermediate rate +7.4%",
+ income_tax_basic_uplift: "Basic rate threshold uplift",
+ income_tax_intermediate_uplift: "Intermediate rate threshold uplift",
+ higher_rate_freeze: "Higher rate threshold freeze",
+ advanced_rate_freeze: "Advanced rate threshold freeze",
+ top_rate_freeze: "Top rate threshold freeze",
};
export default function ScotlandMap({
@@ -23,6 +26,7 @@ export default function ScotlandMap({
onLocalAuthoritySelect = null,
policyName = "SCP Premium for under-ones",
selectedPolicies = [],
+ fixedColorExtent = null,
}) {
const svgRef = useRef(null);
const [internalSelectedLocalAuthority, setInternalSelectedLocalAuthority] = useState(null);
@@ -75,7 +79,10 @@ export default function ScotlandMap({
}, [localAuthorityData]);
// Compute color scale extent from data (min/max of average_gain)
+ // Use fixedColorExtent if provided for consistent coloring across years
const colorExtent = useMemo(() => {
+ if (fixedColorExtent) return fixedColorExtent;
+
if (localAuthorityData.length === 0) return { min: 0, max: 35, type: 'positive' };
const gains = localAuthorityData.map((d) => d.average_gain || 0);
const min = Math.floor(Math.min(...gains));
@@ -87,7 +94,7 @@ export default function ScotlandMap({
else if (max <= 0) type = 'negative';
return { min, max, type };
- }, [localAuthorityData]);
+ }, [localAuthorityData, fixedColorExtent]);
// Highlight and zoom to controlled local authority when it changes
useEffect(() => {
@@ -452,7 +459,7 @@ export default function ScotlandMap({
Local authority impacts, {formatYearRange(selectedYear)}
- This map shows the average annual household gain from the {policyName}
+ This map shows the average annual household impact from the {policyName}
across Scottish local authorities. Darker green indicates larger gains.
@@ -622,7 +629,7 @@ export default function ScotlandMap({
})}
/year
-
Average household gain
+
Average household impact
{/* Policy breakdown - only show if multiple policies selected */}
{tooltipData.policyBreakdown &&
@@ -635,7 +642,7 @@ export default function ScotlandMap({
.map(([reformId, data]) => (
- {POLICY_DISPLAY_NAMES[reformId] || reformId}
+ {POLICY_DISPLAY_NAMES[reformId]}
)}
- {tooltipData.povertyReduction !== undefined && (
- <>
-
- -{parseFloat(tooltipData.povertyReduction).toFixed(2)}pp
-
- Poverty rate reduction
- >
- )}
)}
diff --git a/src/scottish_budget_data/calculators.py b/src/scottish_budget_data/calculators.py
index 6bab351..96aa092 100644
--- a/src/scottish_budget_data/calculators.py
+++ b/src/scottish_budget_data/calculators.py
@@ -1,11 +1,12 @@
"""Calculators for Scottish Budget dashboard metrics.
Each calculator generates a specific type of output data.
+Uses native MicroSeries from PolicyEngine - sim.calculate() returns MicroSeries with weights.
"""
+import microdf as mdf
import numpy as np
import pandas as pd
-from microdf import MicroSeries
from policyengine_uk import Microsimulation
from .reforms import (
@@ -77,6 +78,7 @@ def calculate(self, reform_id: str, reform_name: str) -> list[dict]:
"""Calculate budgetary impact for all years (Scotland only).
Uses fresh simulations per year with proper Reform classes.
+ sim.calculate() returns MicroSeries with weights - .sum() is weighted.
Returns cost in £ millions. Positive = cost to government (income gain for households).
"""
@@ -95,12 +97,14 @@ def calculate(self, reform_id: str, reform_name: str) -> list[dict]:
is_scotland = get_scotland_household_mask(baseline, year)
+ # sim.calculate() returns MicroSeries with weights
baseline_income = baseline.calculate("household_net_income", year)
reformed_income = reformed.calculate("household_net_income", year)
- # Calculate impact: negative = cost to government, positive = revenue
- # (matches OBR/SFC convention used in autumn budget dashboard)
- household_change = (reformed_income[is_scotland] - baseline_income[is_scotland]).sum()
+ # MicroSeries subtraction preserves weights, .sum() is weighted
+ income_change = reformed_income - baseline_income
+ household_change = income_change[is_scotland].sum()
+
# Negate because household income gain = cost to government
impact = -household_change / 1e6
@@ -118,6 +122,7 @@ class DistributionalImpactCalculator:
"""Calculate distributional impact by income decile.
Uses fresh simulations per year with proper PolicyEngine Reform classes.
+ sim.calculate() returns MicroSeries with weights built in.
"""
def calculate(
@@ -125,10 +130,10 @@ def calculate(
reform_id: str,
reform_name: str,
year: int,
- ) -> tuple[list[dict], pd.DataFrame]:
+ ) -> list[dict]:
"""Calculate distributional impact for a single year (Scotland only).
- Uses fresh simulations with proper Reform classes.
+ Uses native MicroSeries from sim.calculate() - no manual weight handling.
"""
baseline = Microsimulation()
reformed = Microsimulation()
@@ -142,32 +147,28 @@ def calculate(
is_scotland = get_scotland_household_mask(baseline, year)
- baseline_income = np.array(baseline.calculate("household_net_income", year))[is_scotland]
- reformed_income = np.array(reformed.calculate("household_net_income", year))[is_scotland]
- household_weight = np.array(baseline.calculate("household_weight", year))[is_scotland]
- income_decile = np.array(baseline.calculate("household_income_decile", year))[is_scotland]
+ # sim.calculate() returns MicroSeries with weights
+ baseline_income = baseline.calculate("household_net_income", year)
+ reformed_income = reformed.calculate("household_net_income", year)
+ income_decile = baseline.calculate("household_income_decile", year)
- df = pd.DataFrame({
- "baseline_income": baseline_income,
- "reformed_income": reformed_income,
- "household_weight": household_weight,
- "income_decile": income_decile,
- })
-
- df["income_change"] = df["reformed_income"] - df["baseline_income"]
- df["income_decile"] = pd.to_numeric(df["income_decile"], errors="coerce").clip(1, 10).astype(int)
+ # Filter to Scotland - MicroSeries preserves weights when filtered
+ baseline_scotland = baseline_income[is_scotland]
+ reformed_scotland = reformed_income[is_scotland]
+ income_change = reformed_scotland - baseline_scotland
+ decile_scotland = income_decile[is_scotland]
results = []
decile_labels = ["1st", "2nd", "3rd", "4th", "5th", "6th", "7th", "8th", "9th", "10th"]
for decile in range(1, 11):
- decile_data = df[df["income_decile"] == decile]
- if len(decile_data) == 0:
+ decile_mask = np.array(decile_scotland) == decile
+ if not decile_mask.any():
continue
- weights = decile_data["household_weight"].values
- avg_change = MicroSeries(decile_data["income_change"].values, weights=weights).mean()
- avg_baseline = MicroSeries(decile_data["baseline_income"].values, weights=weights).mean()
+ # Use native MicroSeries - .mean() is weighted automatically
+ avg_change = income_change[decile_mask].mean()
+ avg_baseline = baseline_scotland[decile_mask].mean()
relative_change = (avg_change / avg_baseline) * 100 if avg_baseline > 0 else 0
results.append({
@@ -180,9 +181,8 @@ def calculate(
})
# Add overall average (All deciles)
- overall_weights = df["household_weight"].values
- overall_avg_change = MicroSeries(df["income_change"].values, weights=overall_weights).mean()
- overall_avg_baseline = MicroSeries(df["baseline_income"].values, weights=overall_weights).mean()
+ overall_avg_change = income_change.mean()
+ overall_avg_baseline = baseline_scotland.mean()
overall_relative_change = (overall_avg_change / overall_avg_baseline) * 100 if overall_avg_baseline > 0 else 0
results.append({
@@ -194,55 +194,7 @@ def calculate(
"absolute_change": overall_avg_change,
})
- return results, df
-
-
-class WinnersLosersCalculator:
- """Calculate winners and losers statistics."""
-
- def calculate(
- self,
- decile_df: pd.DataFrame,
- reform_id: str,
- reform_name: str,
- year: int,
- ) -> list[dict]:
- """Calculate winners/losers from decile DataFrame."""
- # Create MicroSeries with household weights for proper weighted sums
- weights = decile_df["household_weight"].values
- ones = np.ones(len(decile_df))
- total_weight = MicroSeries(ones, weights=weights).sum()
-
- winner_mask = decile_df["income_change"] > 1
- loser_mask = decile_df["income_change"] < -1
-
- winners = MicroSeries(ones[winner_mask], weights=weights[winner_mask]).sum()
- losers = MicroSeries(ones[loser_mask], weights=weights[loser_mask]).sum()
- unchanged = total_weight - winners - losers
-
- return [
- {
- "reform_id": reform_id,
- "reform_name": reform_name,
- "year": year,
- "metric": "winners_pct",
- "value": (winners / total_weight) * 100,
- },
- {
- "reform_id": reform_id,
- "reform_name": reform_name,
- "year": year,
- "metric": "losers_pct",
- "value": (losers / total_weight) * 100,
- },
- {
- "reform_id": reform_id,
- "reform_name": reform_name,
- "year": year,
- "metric": "unchanged_pct",
- "value": (unchanged / total_weight) * 100,
- },
- ]
+ return results
class MetricsCalculator:
@@ -256,22 +208,31 @@ def calculate(
reform_name: str,
year: int,
) -> list[dict]:
- """Calculate poverty and other summary metrics (Scotland only)."""
+ """Calculate poverty and other summary metrics (Scotland only).
+
+ Uses native MicroSeries from sim.calculate() - no manual weight handling.
+ """
is_scotland = get_scotland_person_mask(baseline, year)
- person_weight = baseline.calculate("person_weight", year, map_to="person").values[is_scotland]
- is_child = baseline.calculate("is_child", year, map_to="person").values[is_scotland]
- child_weights = person_weight * is_child
+
+ # Get is_child for filtering to children
+ is_child = baseline.calculate("is_child", year, map_to="person")
+ is_child_scotland = np.array(is_child[is_scotland])
def add_metric_set(
results: list[dict],
metric_prefix: str,
- baseline_values: np.ndarray,
- reformed_values: np.ndarray,
- weights: np.ndarray,
+ baseline_ms, # MicroSeries
+ reformed_ms, # MicroSeries
+ child_filter: np.ndarray = None,
) -> None:
"""Add baseline, reform, and change metrics for a given measure."""
- baseline_rate = MicroSeries(baseline_values, weights=weights).mean() * 100
- reformed_rate = MicroSeries(reformed_values, weights=weights).mean() * 100
+ if child_filter is not None:
+ # Filter to children
+ baseline_rate = baseline_ms[child_filter].mean() * 100
+ reformed_rate = reformed_ms[child_filter].mean() * 100
+ else:
+ baseline_rate = baseline_ms.mean() * 100
+ reformed_rate = reformed_ms.mean() * 100
results.append({
"reform_id": reform_id,
@@ -310,20 +271,29 @@ def add_metric_set(
poverty_var = f"in_relative_poverty_{housing_cost}"
deep_poverty_var = None
- # Regular poverty
- baseline_poverty = baseline.calculate(poverty_var, year, map_to="person").values[is_scotland]
- reformed_poverty = reformed.calculate(poverty_var, year, map_to="person").values[is_scotland]
+ # sim.calculate() returns MicroSeries with weights
+ baseline_poverty = baseline.calculate(poverty_var, year, map_to="person")
+ reformed_poverty = reformed.calculate(poverty_var, year, map_to="person")
+
+ # Filter to Scotland - MicroSeries preserves weights
+ baseline_scotland = baseline_poverty[is_scotland]
+ reformed_scotland = reformed_poverty[is_scotland]
- add_metric_set(results, f"{prefix}poverty_rate", baseline_poverty, reformed_poverty, person_weight)
- add_metric_set(results, f"{prefix}child_poverty_rate", baseline_poverty, reformed_poverty, child_weights)
+ # All persons poverty rate
+ add_metric_set(results, f"{prefix}poverty_rate", baseline_scotland, reformed_scotland)
+ # Child poverty rate
+ add_metric_set(results, f"{prefix}child_poverty_rate", baseline_scotland, reformed_scotland, is_child_scotland)
# Deep poverty (only for absolute poverty measure)
if deep_poverty_var:
- baseline_deep = baseline.calculate(deep_poverty_var, year, map_to="person").values[is_scotland]
- reformed_deep = reformed.calculate(deep_poverty_var, year, map_to="person").values[is_scotland]
+ baseline_deep = baseline.calculate(deep_poverty_var, year, map_to="person")
+ reformed_deep = reformed.calculate(deep_poverty_var, year, map_to="person")
+
+ baseline_deep_scotland = baseline_deep[is_scotland]
+ reformed_deep_scotland = reformed_deep[is_scotland]
- add_metric_set(results, f"{prefix}deep_poverty_rate", baseline_deep, reformed_deep, person_weight)
- add_metric_set(results, f"{prefix}child_deep_poverty_rate", baseline_deep, reformed_deep, child_weights)
+ add_metric_set(results, f"{prefix}deep_poverty_rate", baseline_deep_scotland, reformed_deep_scotland)
+ add_metric_set(results, f"{prefix}child_deep_poverty_rate", baseline_deep_scotland, reformed_deep_scotland, is_child_scotland)
return results
@@ -339,7 +309,10 @@ def calculate(
sim_with_limit: Microsimulation,
sim_without_limit: Microsimulation,
) -> list[dict]:
- """Calculate two-child limit abolition impact for Scotland."""
+ """Calculate two-child limit abolition impact for Scotland.
+
+ Uses native MicroSeries from sim.calculate() - no manual weight handling.
+ """
# SFC estimates for comparison
SFC_DATA = {
2026: {"children": 43000, "cost": 155},
@@ -352,27 +325,30 @@ def calculate(
results = []
for year in self.years:
- region = sim_without_limit.calculate("region", year, map_to="household").values
- scotland_mask = region == "SCOTLAND"
- hh_weight = sim_without_limit.calculate("household_weight", year, map_to="household").values
+ # sim.calculate() returns MicroSeries with weights
+ region = sim_without_limit.calculate("region", year, map_to="household")
+ scotland_mask = np.array(region) == "SCOTLAND"
- uc_without_limit = sim_without_limit.calculate("universal_credit", year, map_to="household").values
- uc_with_limit = sim_with_limit.calculate("universal_credit", year, map_to="household").values
+ uc_without_limit = sim_without_limit.calculate("universal_credit", year, map_to="household")
+ uc_with_limit = sim_with_limit.calculate("universal_credit", year, map_to="household")
+
+ # MicroSeries subtraction preserves weights
uc_gain = uc_without_limit - uc_with_limit
+ affected_mask = scotland_mask & (np.array(uc_gain) > 0)
- affected_mask = scotland_mask & (uc_gain > 0)
- affected_weights = hh_weight[affected_mask]
- total_cost = MicroSeries(uc_gain[affected_mask], weights=affected_weights).sum()
+ # .sum() on MicroSeries is weighted
+ total_cost = uc_gain[affected_mask].sum()
total_cost_millions = total_cost / 1e6
- affected_benefit_units = MicroSeries(np.ones(affected_weights.shape), weights=affected_weights).sum()
- benunit_children = sim_without_limit.calculate(
- "benunit_count_children", year, map_to="household"
- ).values
- affected_children_per_hh = np.maximum(benunit_children - 2, 0)
- total_affected_children = MicroSeries(
- affected_children_per_hh[affected_mask], weights=affected_weights
- ).sum()
+ # Count affected benefit units using MicroSeries weights
+ affected_benefit_units = uc_gain.weights[affected_mask].sum()
+
+ # Count affected children - need MicroSeries for calculated values
+ benunit_children = sim_without_limit.calculate("benunit_count_children", year, map_to="household")
+ affected_children_per_hh = np.maximum(np.array(benunit_children) - 2, 0)
+ # Use weights from uc_gain MicroSeries (same household weights)
+ children_ms = mdf.MicroSeries(affected_children_per_hh, weights=uc_gain.weights.values)
+ total_affected_children = children_ms[affected_mask].sum()
results.append({
"year": year,
@@ -387,7 +363,11 @@ def calculate(
class LocalAuthorityCalculator:
- """Calculate local authority-level impacts."""
+ """Calculate local authority-level impacts.
+
+ Note: This uses external weights from HuggingFace (not simulation weights),
+ so we need to explicitly create MicroSeries with those weights.
+ """
def calculate(
self,
@@ -399,12 +379,9 @@ def calculate(
local_authority_df: pd.DataFrame,
) -> list[dict]:
"""Calculate average impact for each local authority."""
- baseline_income = baseline.calculate(
- "household_net_income", period=year, map_to="household"
- ).values
- reform_income = reformed.calculate(
- "household_net_income", period=year, map_to="household"
- ).values
+ # Get values as arrays (we use external LA weights, not simulation weights)
+ baseline_income = np.array(baseline.calculate("household_net_income", period=year, map_to="household"))
+ reform_income = np.array(reformed.calculate("household_net_income", period=year, map_to="household"))
results = []
@@ -417,8 +394,9 @@ def calculate(
la_weights = weights[idx, :]
- baseline_ms = MicroSeries(baseline_income, weights=la_weights)
- reform_ms = MicroSeries(reform_income, weights=la_weights)
+ # Use external LA weights with MicroSeries
+ baseline_ms = mdf.MicroSeries(baseline_income, weights=la_weights)
+ reform_ms = mdf.MicroSeries(reform_income, weights=la_weights)
avg_baseline = baseline_ms.mean()
avg_reform = reform_ms.mean()
diff --git a/src/scottish_budget_data/modal_app.py b/src/scottish_budget_data/modal_app.py
index 13eef06..50261fb 100644
--- a/src/scottish_budget_data/modal_app.py
+++ b/src/scottish_budget_data/modal_app.py
@@ -107,6 +107,170 @@ def create_situation(inputs: dict, year: int) -> dict:
},
}
+ def create_vectorized_situation(inputs: dict, year: int, income_levels: list) -> dict:
+ """Create a vectorized situation with multiple households at different income levels.
+
+ This allows computing impacts for 100 income levels in a single simulation.
+ """
+ import numpy as np
+
+ is_married = inputs.get("is_married", False)
+ partner_income = inputs.get("partner_income", 0)
+ children_ages = inputs.get("children_ages", [])
+ n_households = len(income_levels)
+
+ people = {}
+ benunits = {}
+ households = {}
+
+ for i, income in enumerate(income_levels):
+ hh_id = f"hh{i}"
+ adult1_id = f"adult1_{i}"
+
+ people[adult1_id] = {
+ "age": {year: 35},
+ "employment_income": {year: float(income)},
+ }
+ members = [adult1_id]
+
+ if is_married:
+ adult2_id = f"adult2_{i}"
+ people[adult2_id] = {
+ "age": {year: 33},
+ "employment_income": {year: float(partner_income)},
+ }
+ members.append(adult2_id)
+
+ for j, age in enumerate(children_ages):
+ child_id = f"child{j}_{i}"
+ people[child_id] = {"age": {year: int(age)}}
+ members.append(child_id)
+
+ benunits[f"benunit_{i}"] = {"members": members}
+ households[hh_id] = {"members": members, "region": {year: "SCOTLAND"}}
+
+ return {
+ "people": people,
+ "benunits": benunits,
+ "households": households,
+ }
+
+ def calculate_vectorized_by_income(inputs: dict, year: int, receives_uc: bool) -> list:
+ """Calculate impacts for 100 income levels using vectorization.
+
+ Returns list of {income, total, ...impacts} dicts.
+ """
+ import numpy as np
+
+ # 50 income levels from £0 to £200k (faster computation)
+ income_levels = list(range(0, 200001, 4000)) # 0, 4000, 8000, ..., 200000
+ n = len(income_levels)
+
+ situation = create_vectorized_situation(inputs, year, income_levels)
+
+ # Baseline simulation
+ baseline_sim = Simulation(situation=situation)
+ set_scp_baseline_rate(baseline_sim, year)
+ disable_scp_baby_boost(baseline_sim, year)
+ baseline_sim.calculate("scottish_child_payment", year)
+ baseline_nets = baseline_sim.calculate("household_net_income", year)
+
+ results = {
+ "income_tax_basic_uplift": np.zeros(n),
+ "income_tax_intermediate_uplift": np.zeros(n),
+ "higher_rate_freeze": np.zeros(n),
+ "advanced_rate_freeze": np.zeros(n),
+ "top_rate_freeze": np.zeros(n),
+ "scp_inflation": np.zeros(n),
+ "scp_baby_boost": np.zeros(n),
+ }
+
+ # 1. Basic rate uplift
+ basic_sim = Simulation(situation=situation)
+ set_scp_baseline_rate(basic_sim, year)
+ disable_scp_baby_boost(basic_sim, year)
+ apply_basic_rate_uplift(basic_sim, year)
+ basic_sim.calculate("scottish_child_payment", year)
+ basic_nets = basic_sim.calculate("household_net_income", year)
+ results["income_tax_basic_uplift"] = basic_nets - baseline_nets
+
+ # 2. Intermediate rate uplift
+ intermediate_sim = Simulation(situation=situation)
+ set_scp_baseline_rate(intermediate_sim, year)
+ disable_scp_baby_boost(intermediate_sim, year)
+ apply_intermediate_rate_uplift(intermediate_sim, year)
+ intermediate_sim.calculate("scottish_child_payment", year)
+ intermediate_nets = intermediate_sim.calculate("household_net_income", year)
+ results["income_tax_intermediate_uplift"] = intermediate_nets - baseline_nets
+
+ # 3. Higher rate freeze
+ higher_sim = Simulation(situation=situation)
+ set_scp_baseline_rate(higher_sim, year)
+ disable_scp_baby_boost(higher_sim, year)
+ apply_higher_rate_freeze(higher_sim, year)
+ higher_sim.calculate("scottish_child_payment", year)
+ higher_nets = higher_sim.calculate("household_net_income", year)
+ results["higher_rate_freeze"] = higher_nets - baseline_nets
+
+ # 4. Advanced rate freeze
+ advanced_sim = Simulation(situation=situation)
+ set_scp_baseline_rate(advanced_sim, year)
+ disable_scp_baby_boost(advanced_sim, year)
+ apply_advanced_rate_freeze(advanced_sim, year)
+ advanced_sim.calculate("scottish_child_payment", year)
+ advanced_nets = advanced_sim.calculate("household_net_income", year)
+ results["advanced_rate_freeze"] = advanced_nets - baseline_nets
+
+ # 5. Top rate freeze
+ top_sim = Simulation(situation=situation)
+ set_scp_baseline_rate(top_sim, year)
+ disable_scp_baby_boost(top_sim, year)
+ apply_top_rate_freeze(top_sim, year)
+ top_sim.calculate("scottish_child_payment", year)
+ top_nets = top_sim.calculate("household_net_income", year)
+ results["top_rate_freeze"] = top_nets - baseline_nets
+
+ # 6. SCP inflation (only if receives UC)
+ if receives_uc:
+ scp_inf_sim = Simulation(situation=situation)
+ apply_scp_inflation(scp_inf_sim, year)
+ disable_scp_baby_boost(scp_inf_sim, year)
+ scp_inf_sim.calculate("scottish_child_payment", year)
+ scp_inf_nets = scp_inf_sim.calculate("household_net_income", year)
+ results["scp_inflation"] = scp_inf_nets - baseline_nets
+
+ # 7. SCP baby boost (only if receives UC and year >= 2027)
+ if receives_uc and year >= 2027:
+ baby_sim = Simulation(situation=situation)
+ apply_scp_inflation(baby_sim, year)
+ apply_scp_baby_boost(baby_sim, year)
+ baby_sim.calculate("scottish_child_payment", year)
+ baby_nets = baby_sim.calculate("household_net_income", year)
+ no_baby_sim = Simulation(situation=situation)
+ apply_scp_inflation(no_baby_sim, year)
+ disable_scp_baby_boost(no_baby_sim, year)
+ no_baby_sim.calculate("scottish_child_payment", year)
+ no_baby_nets = no_baby_sim.calculate("household_net_income", year)
+ results["scp_baby_boost"] = baby_nets - no_baby_nets
+
+ # Build output list
+ output = []
+ for i, income in enumerate(income_levels):
+ total = sum(float(results[k][i]) for k in results)
+ output.append({
+ "income": income,
+ "income_tax_basic_uplift": round(float(results["income_tax_basic_uplift"][i]), 2),
+ "income_tax_intermediate_uplift": round(float(results["income_tax_intermediate_uplift"][i]), 2),
+ "higher_rate_freeze": round(float(results["higher_rate_freeze"][i]), 2),
+ "advanced_rate_freeze": round(float(results["advanced_rate_freeze"][i]), 2),
+ "top_rate_freeze": round(float(results["top_rate_freeze"][i]), 2),
+ "scp_inflation": round(float(results["scp_inflation"][i]), 2),
+ "scp_baby_boost": round(float(results["scp_baby_boost"][i]), 2),
+ "total": round(total, 2),
+ })
+
+ return output
+
def apply_basic_rate_uplift(sim, year: int) -> None:
"""Apply basic rate threshold uplift (7.4% in 2026, then CPI uprated)."""
scotland_rates = sim.tax_benefit_system.parameters.gov.hmrc.income_tax.rates.scotland.rates
@@ -371,12 +535,15 @@ def calculate_for_year(inputs: dict, year: int, receives_uc: bool) -> dict:
@flask_app.route("/calculate-all", methods=["POST"])
def calculate_all():
- """Combined endpoint: returns yearly data (2026-2030) in one request."""
+ """Combined endpoint: returns yearly data (2026-2030) in one request.
+
+ by_income is now a separate endpoint to keep this fast.
+ """
try:
inputs = request.get_json()
receives_uc = inputs.get("receives_uc", True)
- # Calculate for all years
+ # Calculate for all years (sequential - each year is fast)
years = [2026, 2027, 2028, 2029, 2030]
yearly_data = [calculate_for_year(inputs, year, receives_uc) for year in years]
@@ -388,6 +555,24 @@ def calculate_all():
traceback.print_exc()
return jsonify({"error": str(e)}), 500
+ @flask_app.route("/calculate-by-income", methods=["POST"])
+ def calculate_by_income():
+ """Separate endpoint for by_income data (vectorized 50 income levels)."""
+ try:
+ inputs = request.get_json()
+ receives_uc = inputs.get("receives_uc", True)
+ year = inputs.get("year", 2027)
+
+ by_income_data = calculate_vectorized_by_income(inputs, year, receives_uc)
+
+ return jsonify({
+ "by_income": by_income_data,
+ })
+ except Exception as e:
+ import traceback
+ traceback.print_exc()
+ return jsonify({"error": str(e)}), 500
+
@flask_app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "healthy"})
diff --git a/src/scottish_budget_data/pipeline.py b/src/scottish_budget_data/pipeline.py
index 5592f5d..f34d5a0 100644
--- a/src/scottish_budget_data/pipeline.py
+++ b/src/scottish_budget_data/pipeline.py
@@ -18,7 +18,6 @@
DistributionalImpactCalculator,
MetricsCalculator,
TwoChildLimitCalculator,
- WinnersLosersCalculator,
)
from .reforms import ReformDefinition, get_scottish_budget_reforms
@@ -73,7 +72,6 @@ def generate_all_data(
# Initialize calculators
budgetary_calc = BudgetaryImpactCalculator(years=years)
distributional_calc = DistributionalImpactCalculator()
- winners_losers_calc = WinnersLosersCalculator()
metrics_calc = MetricsCalculator()
local_authority_calc = LocalAuthorityCalculator()
@@ -102,7 +100,6 @@ def generate_all_data(
# Aggregate results
all_budgetary = []
all_distributional = []
- all_winners_losers = []
all_metrics = []
all_local_authorities = []
@@ -128,17 +125,11 @@ def generate_all_data(
print(f" Year {year}...")
# Distributional
- distributional, decile_df = distributional_calc.calculate(
+ distributional = distributional_calc.calculate(
reform.id, reform.name, year
)
all_distributional.extend(distributional)
- # Winners/losers
- winners_losers = winners_losers_calc.calculate(
- decile_df, reform.id, reform.name, year
- )
- all_winners_losers.extend(winners_losers)
-
# Summary metrics (poverty)
metrics = metrics_calc.calculate(
baseline, reformed, reform.id, reform.name, year
@@ -158,7 +149,6 @@ def generate_all_data(
results = {
"budgetary_impact": pd.DataFrame(all_budgetary),
"distributional_impact": pd.DataFrame(all_distributional),
- "winners_losers": pd.DataFrame(all_winners_losers),
"metrics": pd.DataFrame(all_metrics),
"local_authorities": pd.DataFrame(all_local_authorities),
}
diff --git a/src/utils/reformConfig.js b/src/utils/reformConfig.js
index 842dd50..38cc976 100644
--- a/src/utils/reformConfig.js
+++ b/src/utils/reformConfig.js
@@ -22,7 +22,7 @@ export const REFORMS = [
id: "income_tax_intermediate_uplift",
name: "Intermediate rate threshold uplift",
description: "Intermediate rate threshold raised from £27,492 to £29,527 (+7.4%)",
- color: "#14B8A6", // Teal 500
+ color: "#0F766E", // Teal 700
type: "positive",
},
// Income tax threshold freezes (cost households - revenue raising)
@@ -30,21 +30,21 @@ export const REFORMS = [
id: "higher_rate_freeze",
name: "Higher rate threshold freeze",
description: "Higher rate threshold frozen at £43,662 until 2028-29",
- color: "#F97316", // Orange 500
+ color: "#78350F", // Amber 900
type: "negative",
},
{
id: "advanced_rate_freeze",
name: "Advanced rate threshold freeze",
description: "Advanced rate threshold frozen at £75,000 until 2028-29",
- color: "#FB923C", // Orange 400
+ color: "#92400E", // Amber 800
type: "negative",
},
{
id: "top_rate_freeze",
name: "Top rate threshold freeze",
description: "Top rate threshold frozen at £125,140 until 2028-29",
- color: "#FDBA74", // Orange 300
+ color: "#B45309", // Amber 700
type: "negative",
},
// Scottish Child Payment
@@ -52,14 +52,14 @@ export const REFORMS = [
id: "scp_inflation",
name: "SCP inflation adjustment",
description: "Scottish Child Payment uprated from £27.15 to £28.20/week",
- color: "#2DD4BF", // Teal 400
+ color: "#14B8A6", // Teal 500
type: "positive",
},
{
id: "scp_baby_boost",
name: "SCP Premium for under-ones",
description: "Scottish Child Payment raised to £40/week for babies under 1",
- color: "#5EEAD4", // Teal 300
+ color: "#2DD4BF", // Teal 400
type: "positive",
},
];
diff --git a/tests/test_data_validation.py b/tests/test_data_validation.py
index fdd30ab..ca7ecb7 100644
--- a/tests/test_data_validation.py
+++ b/tests/test_data_validation.py
@@ -20,12 +20,6 @@ def distributional_impact():
return pd.read_csv(DATA_DIR / "distributional_impact.csv")
-@pytest.fixture
-def winners_losers():
- """Load winners/losers data."""
- return pd.read_csv(DATA_DIR / "winners_losers.csv")
-
-
@pytest.fixture
def metrics():
"""Load metrics data."""
@@ -164,16 +158,6 @@ def test_distributional_impact_has_all_deciles(distributional_impact):
assert deciles == expected
-def test_winners_losers_percentages_sum_to_100(winners_losers):
- """Test winners/losers percentages sum to approximately 100%."""
- # Data format: metric column has winners_pct, losers_pct, unchanged_pct
- for (reform_id, year), group in winners_losers.groupby(["reform_id", "year"]):
- # Pivot to get percentages as columns
- metrics = group.set_index("metric")["value"]
- total = metrics.get("winners_pct", 0) + metrics.get("losers_pct", 0) + metrics.get("unchanged_pct", 0)
- assert 99 < total < 101, f"Reform {reform_id} year {year}: percentages sum to {total}%, not ~100%"
-
-
def test_all_data_files_exist():
"""Test that all expected data files exist."""
expected_files = [
@@ -181,7 +165,6 @@ def test_all_data_files_exist():
"constituency.csv",
"distributional_impact.csv",
"metrics.csv",
- "winners_losers.csv",
]
for filename in expected_files: