Forecast improvements
This commit is contained in:
@@ -357,6 +357,9 @@ router.get('/forecast/metrics', async (req, res) => {
|
||||
|
||||
const active = parseInt(totals.active_products) || 1;
|
||||
const curveProducts = parseInt(totals.curve_products) || 0;
|
||||
// NOTE: despite the name, this is "share of active products forecast via
|
||||
// lifecycle curves" (curve coverage), NOT a statistical confidence. It only
|
||||
// feeds a per-day tooltip field. See FORECAST_FIX_PLAN F9 (point 4).
|
||||
const confidenceLevel = parseFloat((curveProducts / active).toFixed(2));
|
||||
|
||||
// Daily series from actual forecast
|
||||
@@ -687,14 +690,29 @@ router.get('/forecast/accuracy', async (req, res) => {
|
||||
const { rows: metrics } = await executeQuery(`
|
||||
SELECT metric_type, dimension_value, sample_size,
|
||||
total_actual_units, total_forecast_units,
|
||||
mae, wmape, bias, rmse
|
||||
mae, wmape, bias, rmse, naive_wmape, fva
|
||||
FROM forecast_accuracy
|
||||
WHERE run_id = $1
|
||||
ORDER BY metric_type, dimension_value
|
||||
`, [latestRunId]);
|
||||
|
||||
// Shared shaping for an "overall"-style aggregate row (daily or weekly grain).
|
||||
const shapeOverall = (m) => m ? {
|
||||
sampleSize: parseInt(m.sample_size),
|
||||
totalActual: parseFloat(m.total_actual_units) || 0,
|
||||
totalForecast: parseFloat(m.total_forecast_units) || 0,
|
||||
mae: m.mae != null ? parseFloat(parseFloat(m.mae).toFixed(4)) : null,
|
||||
wmape: m.wmape != null ? parseFloat((parseFloat(m.wmape) * 100).toFixed(1)) : null,
|
||||
bias: m.bias != null ? parseFloat(parseFloat(m.bias).toFixed(4)) : null,
|
||||
rmse: m.rmse != null ? parseFloat(parseFloat(m.rmse).toFixed(4)) : null,
|
||||
naiveWmape: m.naive_wmape != null ? parseFloat((parseFloat(m.naive_wmape) * 100).toFixed(1)) : null,
|
||||
fva: m.fva != null ? parseFloat(parseFloat(m.fva).toFixed(3)) : null,
|
||||
} : null;
|
||||
|
||||
// Organize into response structure
|
||||
const overall = metrics.find(m => m.metric_type === 'overall');
|
||||
const overall = metrics.find(m => m.metric_type === 'overall' && m.dimension_value === 'all')
|
||||
const overallInclDormant = metrics.find(m => m.metric_type === 'overall' && m.dimension_value === 'all_incl_dormant')
|
||||
const overallWeekly = metrics.find(m => m.metric_type === 'overall_weekly');
|
||||
const byPhase = metrics
|
||||
.filter(m => m.metric_type === 'by_phase')
|
||||
.map(m => ({
|
||||
@@ -706,6 +724,8 @@ router.get('/forecast/accuracy', async (req, res) => {
|
||||
wmape: m.wmape != null ? parseFloat((parseFloat(m.wmape) * 100).toFixed(1)) : null,
|
||||
bias: m.bias != null ? parseFloat(parseFloat(m.bias).toFixed(4)) : null,
|
||||
rmse: m.rmse != null ? parseFloat(parseFloat(m.rmse).toFixed(4)) : null,
|
||||
naiveWmape: m.naive_wmape != null ? parseFloat((parseFloat(m.naive_wmape) * 100).toFixed(1)) : null,
|
||||
fva: m.fva != null ? parseFloat(parseFloat(m.fva).toFixed(3)) : null,
|
||||
}))
|
||||
.sort((a, b) => (b.totalActual || 0) - (a.totalActual || 0));
|
||||
|
||||
@@ -763,6 +783,26 @@ router.get('/forecast/accuracy', async (req, res) => {
|
||||
sampleSize: parseInt(r.sample_size),
|
||||
}));
|
||||
|
||||
// Weekly-grain trend across runs (starts empty for old runs that predate
|
||||
// the overall_weekly metric — that's expected, no backfill). F9.
|
||||
const { rows: weeklyTrendRows } = await executeQuery(`
|
||||
SELECT fr.finished_at::date AS run_date,
|
||||
fa.wmape, fa.naive_wmape, fa.fva, fa.sample_size
|
||||
FROM forecast_accuracy fa
|
||||
JOIN forecast_runs fr ON fr.id = fa.run_id
|
||||
WHERE fa.metric_type = 'overall_weekly'
|
||||
AND fa.dimension_value = 'all'
|
||||
ORDER BY fr.finished_at
|
||||
`);
|
||||
|
||||
const accuracyTrendWeekly = weeklyTrendRows.map(r => ({
|
||||
date: r.run_date instanceof Date ? r.run_date.toISOString().split('T')[0] : r.run_date,
|
||||
wmape: r.wmape != null ? parseFloat((parseFloat(r.wmape) * 100).toFixed(1)) : null,
|
||||
naiveWmape: r.naive_wmape != null ? parseFloat((parseFloat(r.naive_wmape) * 100).toFixed(1)) : null,
|
||||
fva: r.fva != null ? parseFloat(parseFloat(r.fva).toFixed(3)) : null,
|
||||
sampleSize: parseInt(r.sample_size),
|
||||
}));
|
||||
|
||||
res.json({
|
||||
hasData: true,
|
||||
computedAt,
|
||||
@@ -775,20 +815,15 @@ router.get('/forecast/accuracy', async (req, res) => {
|
||||
? historyInfo.latest_date.toISOString().split('T')[0]
|
||||
: historyInfo.latest_date,
|
||||
},
|
||||
overall: overall ? {
|
||||
sampleSize: parseInt(overall.sample_size),
|
||||
totalActual: parseFloat(overall.total_actual_units) || 0,
|
||||
totalForecast: parseFloat(overall.total_forecast_units) || 0,
|
||||
mae: overall.mae != null ? parseFloat(parseFloat(overall.mae).toFixed(4)) : null,
|
||||
wmape: overall.wmape != null ? parseFloat((parseFloat(overall.wmape) * 100).toFixed(1)) : null,
|
||||
bias: overall.bias != null ? parseFloat(parseFloat(overall.bias).toFixed(4)) : null,
|
||||
rmse: overall.rmse != null ? parseFloat(parseFloat(overall.rmse).toFixed(4)) : null,
|
||||
} : null,
|
||||
overall: shapeOverall(overall),
|
||||
overallInclDormant: shapeOverall(overallInclDormant),
|
||||
overallWeekly: shapeOverall(overallWeekly),
|
||||
byPhase,
|
||||
byLeadTime,
|
||||
byMethod,
|
||||
dailyTrend,
|
||||
accuracyTrend,
|
||||
accuracyTrendWeekly,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching forecast accuracy:', err);
|
||||
|
||||
Reference in New Issue
Block a user