Skip to content

NMS-20152: Forecasting and descriptive legend states on Resource Graphs - #8730

Open
joseanesONMS wants to merge 9 commits into
developfrom
jira/NMS-20152-resource-graphs
Open

NMS-20152: Forecasting and descriptive legend states on Resource Graphs#8730
joseanesONMS wants to merge 9 commits into
developfrom
jira/NMS-20152-resource-graphs

Conversation

@joseanesONMS

@joseanesONMS joseanesONMS commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Reintroduce forecasting on the new Resource Graphs page and replace the "NaN" legend stats with descriptive states.

The forecast runs entirely through the existing server-side measurement filters — no R dependency, and no client-side statistics to keep in sync.

  • The overlay (Holt-Winters fit, confidence bounds, polynomial trend) comes from a single POST /rest/measurements running the Outlier → HoltWinters → Trend → Chomp chain; the page only renders the returned columns.
  • Holt-Winters is multiplicative with bounds delta = z·σ·√min(h, period), matching the shipped HWForecast filter — the bounds widen and then plateau at one seasonal period rather than fanning open.
  • Graph Start drives the trailing Chomp cutoff, so training uses the full window while only the visible range is returned.
  • 1-day / 7-day / 31-day templates plus a Custom panel with per-field validation (confidence and outlier thresholds bounded, season × 2 < training window, integers ≥ 1).
  • A banner surfaces forecast failure conditions — no fit, an all-NaN fit (including the multiplicative zero-in-season case), or zero-width bounds — matching the legacy page's checkForecastWarning.
  • Legend: a stat of exactly 0 printed "NaN" (d3 formatPrefix) — now renders 0.00; non-finite stats render No Data or Invalid Data.
  • Covered by ForecastGraph.test.ts (data wiring and the warning conditions) and the extended legend-state tests; full ui suite green.

Legend stats of exactly 0 rendered "NaN" because d3 formatPrefix cannot
derive an SI prefix for 0, and genuinely non-finite stats also printed
"NaN". Zeros now render as a fixed-precision number, and non-finite stats
render as No Data (the source column had no samples) or Invalid Data (it
had samples but the result is non-finite).

Reintroduces the Horizon 36 forecasting feature entirely client-side; the
legacy measurement filters require R, which is not available on every
install. A Forecasting action on each graph opens an additive Holt-Winters
forecast with polynomial trend, outlier removal, and confidence bounds that
widen across the horizon, driven by 1/7/31-day templates or a validated
custom-options panel.

NMS-20152
…ource-graphs

# Conflicts:
#	ui/src/components/Resources/Graph.vue
Swap the forecast controls from direct primevue Select/InputNumber to
OnmsSelect/OnmsInputNumber, and move the metric/template/custom-option fields
onto the FormField top-label wrapper (label, hint, and per-field error) instead
of hand-rolled label/small markup. The select change handlers move to
update:modelValue, which OnmsSelect emits. Clean pnpm lint.
@synqotik

Copy link
Copy Markdown
Contributor

@joseanesONMS fix merge issues and update re seam. Also run pnpm lint:fix to fix formatting, etc. Other than that looks good (assuming all the algorithms are correct...).

Mount ForecastGraph with the chart, services, converter, and forecast math
stubbed, and cover the load-and-fetch path, the no-forecastable-metrics load
error, a definition-load failure, and running a forecast (refetch + compute).
@joseanesONMS

Copy link
Copy Markdown
Contributor Author

@marshallmassengill @synqotik ready for review.

Adds a Forecasting screen for Resource Graphs (Holt-Winters + polynomial trend, client-side) and descriptive legend states. Since opening:

  • Forecast controls are on the @opennms/onms-ui seam — OnmsSelect/OnmsInputNumber inside FormField (no direct PrimeVue, no hand-rolled labels).
  • Added component tests for ForecastGraph.vue alongside the existing forecast-math unit tests.
  • Rebased onto the latest develop.

pnpm lint clean; UI build + tests green.

@marshallmassengill marshallmassengill left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple blockers:

Really validate #1 because it's pulling in R info from somewhere and it's been removed for a while. We need to make sure that is cleared up.

  1. The stated premise is false. The server-side forecast has no R dependency. HWForecast.java is @FilterINFO(name="HoltWinters"), pure commons-math3, and HWForecast.checkForecastSupport() is // noop, forecasting always supported now. HoltWintersR is a separate, unused filter. The legacy index.js builds Chomp/Outlier/HoltWinters/Trend — every one of them R-free. The only stale artifact is forecast.jsp's error string, which can never fire. The real blocker was that GraphMetricsPayload has no filters field while QueryRequest does (@xmlelement(name="filter")); that's a ~10-line type addition, not 300 lines of hand-rolled statistics and a second HW implementation to keep in sync.

  2. graphStart silently does nothing on the forecast plot. runForecast only reads trainingStart; the legacy chain ended with Chomp{cutoffDate: graphStartInMillis}. The field is offered, validated, and shipped in all three templates while having no effect, and forecasting.adoc says "Graph Start sets what's actually visible on screen."

  3. removeOutliers is two-sided; the filter it replaces is not. OutlierFilter.java only NaNs values greater than the quantile. The new version also clips below 1-q, which deletes the seasonal troughs Holt-Winters exists to learn. Verified on a capacity-planning shape (500 + 0.4·i, small daily ripple, 14d/1000 samples): 25 leading and 25 trailing samples dropped — the newest 8.4 hours discarded, forecast grid shifted 8.4h into the past, no warning. Same 2.5% on the 31-day template is ~9 days.

  4. The change contradicts the docs it ships alongside. forecasting.adoc states multiplicative seasonality (new code is additive), delta = z·σ·√min(h, period) (new code is uncapped √h), Trend #00ffff (new code #00b4d8). HWForecast.java caps the horizon deliberately: "so bounds widen briefly and then plateau rather than fanning open without bound." The PR lists removing that cap as a feature. Docs aren't touched.

The forecast is no longer computed in the browser. ForecastGraph now posts an
Outlier -> HoltWinters -> Trend -> Chomp filter chain to /measurements and
renders the server's HWFit/HWLwr/HWUpr/Trend columns, so the math is the
existing R-free HWForecast rather than a second hand-rolled implementation to
keep in sync. This makes Graph Start effective (the trailing Chomp cutoff),
uses the server's one-sided Outlier filter, and matches forecasting.adoc.

Adds a filter field to GraphMetricsPayload (FilterDef/FilterParamDef), deletes
the client-side statistics (forecasting.ts) and its unit tests, and updates the
component test to assert the posted filter chain. Trend color and the warnings
section in the docs are reconciled with the shipped behavior.
@github-actions github-actions Bot added the docs label Aug 19, 2026
@github-actions
github-actions Bot requested a review from indigo423 August 19, 2026 13:14
@joseanesONMS

Copy link
Copy Markdown
Contributor Author

@marshallmassengill You were right on every count — validated, then pivoted the whole feature to server-side forecasting:

#1 (false premise / use the server's R-free HWForecast). Confirmed: HWForecast is @FilterInfo("HoltWinters"), pure commons-math3, checkForecastSupport() is a noop, and QueryRequest already takes a filter chain while GraphMetricsPayload didn't. ForecastGraph now POSTs an Outlier → HoltWinters → Trend → Chomp chain to /measurements and renders the returned HWFit/HWLwr/HWUpr/Trend columns. Deleted forecasting.ts (the ~300 lines of hand-rolled statistics + the second HW impl) and its unit tests; added the filter field to GraphMetricsPayload.

#2 (graphStart did nothing). Now effective — it's the trailing Chomp cutoffDate, so training uses the full window and only Graph Start → horizon is drawn.

#3 (two-sided outlier). Gone — we use the server's one-sided Outlier filter.

#4 (docs contradictions). Resolved by using the server: the delta is now HWForecast's capped z·σ·√min(h, period), seasonality/behavior is whatever HWForecast does (single source of truth), Trend is back to #00ffff, and I updated the warnings section in forecasting.adoc to match the shipped behavior.

@synqotik seam + lint addressed too: the rewritten ForecastGraph stays on OnmsSelect/OnmsInputNumber/FormField, pnpm lint is clean, and it's rebased on the latest develop.

UI: lint clean, vue-tsc build passes, resource-graph tests green (the 8 unrelated failures are pre-existing localStorage/theme service tests from the develop merge). Deploying to my local instance next to eyeball the server-backed overlays.

@marshallmassengill marshallmassengill left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple issues:

Forecast failures are silent. runForecast only warns when resp.timestamps is empty.

HWForecast hardcodes MULTIPLICATIVE seasonality, so any metric that reaches 0 inside its season divides by zero. The user gets a green line, a cyan trend, three empty legend entries, and no explanation. The same commit deletes the three documented warnings for exactly these conditions from forecasting.adoc, and core/web-assets/.../forecast/checkForecastWarning.js, which implements them, is still in-tree.

runForecast only warned when the measurements response was empty, so a
response whose timestamps are present but whose Holt-Winters columns are
empty or all-NaN rendered a bare data line with three empty legend entries
and no explanation. Inspect the returned columns the way the legacy forecast
page does (via checkForecastWarning.js) and show a banner when: no HWFit
column is returned, the fit is all-NaN (including the multiplicative
Holt-Winters divide-by-zero when the metric reaches zero in-season), or the
confidence bounds have zero width. Restore the per-condition warnings table
in forecasting.adoc to match.
@joseanesONMS

Copy link
Copy Markdown
Contributor Author

@marshallmassengill — both addressed.

Silent forecast failures. runForecast now inspects the returned columns instead of only checking for empty timestamps, mirroring the conditions the legacy page reports via checkForecastWarning.js. The historical data line still renders, and a banner explains the missing/degenerate overlay when: no HWFit column comes back, the fit is all-NaN, or the confidence bounds have zero width.

Multiplicative-seasonality zero. The all-NaN-fit case is reported specifically when the source series touches zero within the window — "the metric reaches zero within its season, which the multiplicative Holt-Winters model cannot forecast" — so that green-line-plus-empty-legend case is no longer unexplained. I left checkForecastWarning.js in place: it's still live for the legacy forecast.jsp (apps/forecast/index.js requires it), so rather than remove or duplicate it I ported the same checks into the Vue component. The per-condition warnings table is restored in forecasting.adoc, with the zero-in-season cause added.

Component tests cover all three banner conditions; UI eslint + vitest + vue-tsc build pass.

@joseanesONMS

Copy link
Copy Markdown
Contributor Author

@marshallmassengill friendly ping — the forecast failure banners are pushed (no fit / all-NaN fit incl. the multiplicative zero-in-season case / zero-width bounds), the warnings table is restored in the docs, and I've corrected the stale PR description that still described the old client-side approach. Ready for another pass.

@marshallmassengill marshallmassengill left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bit a blocker here:

ForecastGraph.vue:190,379 — isFetchable requires attribute && resourceId && !expression, so only DEF-backed metrics survive the series filter. But RrdGraphConverter._onLine/_onArea set series.metric to the drawn source, which in most stock definitions is a CDEF ({name, expression}, no attribute or resourceId). Those series are dropped; when a report draws only CDEFs (we have quite a few graphs that do this) the page falls through to "This graph has no forecastable metrics" and no forecast is possible.

The legacy page posted the whole model — DEFs as source, CDEFs as expression, the shape Graph.vue:216-243 already builds — and passed the series name as inputColumn. The single-source label: 'data' shortcut is what rules CDEFs out.

@OpenNMS OpenNMS deleted a comment Aug 24, 2026
… !smoke

The forecast tab only admitted DEF-backed series (attribute + resourceId,
no expression), so graphs whose drawn lines are computed expressions - most
stock definitions - reported nothing forecastable. Post the whole model
instead, DEFs as sources and CDEFs as expressions (the same payload
Graph.vue builds), and hand the selected series' metric name to the filter
chain as inputColumn, mirroring the legacy forecast page.

The selected column is now located by response label rather than assumed
at index 0; verified against a live instance that the measurements API
returns HW fit/bounds and trend for an expression-backed inputColumn, with
the data column ordered last.
@joseanesONMS

Copy link
Copy Markdown
Contributor Author

Reworked the forecast fetch per your last review: the tab now posts the whole graph model — DEFs as source, CDEFs as expression, the same payload Graph.vue builds — and passes the selected series' metric name to the filter chain as inputColumn, matching the legacy page. The isFetchable DEF-only gate and the single-source label: 'data' shortcut are gone, so expression-drawn series (most stock graphs) are forecastable again.

The selected column is located by response label instead of assumed at index 0; probed against a live instance that an expression-backed inputColumn returns HW fit/bounds and trend, with the data column ordered last in the response. Tests cover the CDEF path end-to-end (model payload shape + inputColumn), and the branch is refreshed with current develop.

An epoch marker and a tmlog accidentally rode an earlier squash-merge
onto develop; they are local integration-test residue with no role in
the build. Also re-runs the smoke suite, which timed out at 66 minutes
with all 258 tests passing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants