This is an automated email from the ASF dual-hosted git repository. Gerrrr pushed a commit to branch multiple-algorithms-single-point in repository https://gitbox.apache.org/repos/asf/otava-playground.git
commit 3d7a1378a69a36ec44ac5267e90ca2250680966d Author: Alex Sorokoumov <[email protected]> AuthorDate: Sun Jul 5 10:47:32 2026 -0700 Render single-mode Otava results per algorithm Fetch single-pattern Otava results through /api/compare and render each enabled algorithm separately in the chart, accuracy table, stats, and comparison tables. --- src/otava_test_data/web/static/css/style.css | 33 +++ src/otava_test_data/web/static/js/app.js | 385 ++++++++++++++++++++------- src/otava_test_data/web/templates/index.html | 2 + 3 files changed, 317 insertions(+), 103 deletions(-) diff --git a/src/otava_test_data/web/static/css/style.css b/src/otava_test_data/web/static/css/style.css index 59830aa..533072d 100644 --- a/src/otava_test_data/web/static/css/style.css +++ b/src/otava_test_data/web/static/css/style.css @@ -2128,4 +2128,37 @@ body[data-mode="dataset"] .hide-in-dataset { color: #cbd5e1; } .matched-by-list .matched-entry:last-child::after { content: ""; } + +/* Per-algorithm error notice inside the Otava controls panel. */ +.otava-error-notice { + margin-top: 8px; + padding: 6px 8px; + background: #fef2f2; + border: 1px solid #fecaca; + border-left: 3px solid #ef4444; + border-radius: 4px; + color: #991b1b; + font-size: 0.75rem; + line-height: 1.4; +} +.otava-error-notice.hidden { display: none; } +.otava-error-notice ul { + margin: 0; + padding-left: 18px; +} + +/* Per-algorithm color swatches rendered inside the Otava chart header. */ +.otava-algo-swatches { + display: inline-flex; + align-items: center; + gap: 10px; + margin-left: 12px; + font-size: 0.75rem; + color: #475569; +} +.otava-algo-swatches .swatch { + display: inline-flex; + align-items: center; + gap: 4px; +} .matched-entry-miss { color: #cbd5e1; } diff --git a/src/otava_test_data/web/static/js/app.js b/src/otava_test_data/web/static/js/app.js index 68d1ded..d2af0dd 100644 --- a/src/otava_test_data/web/static/js/app.js +++ b/src/otava_test_data/web/static/js/app.js @@ -1312,53 +1312,66 @@ async function generateData() { const name = selectedGenerator; const length = lengthInput.value; const seed = seedInput.value; - const runOtava = isOtavaEnabled(); + const otavaEnabled = isOtavaEnabled(); - // Single-pattern mode only renders the primary algorithm's detections; - // the backend reads only that algorithm's params, so send just those. - const algo = primaryOtavaAlgorithm(); - const algoParams = getOtavaParamsForAlgo(algo); - - const params = new URLSearchParams({ - length, - seed, - run_otava: runOtava, - otava_algorithm: algo, + // /api/generate now only returns series + ground truth; detections come + // from /api/compare so we can show per-algorithm results. + const genParams = new URLSearchParams({ + length, seed, + run_otava: 'false', tolerance: DEFAULT_TOLERANCE, - ...algoParams, }); - // Add dynamic params const paramInputs = dynamicParams.querySelectorAll('input'); - paramInputs.forEach(input => { - params.append(input.name, input.value); - }); + paramInputs.forEach(input => { genParams.append(input.name, input.value); }); try { document.body.classList.add('loading'); - const response = await fetch(`/api/generate/${name}?${params}`); + const response = await fetch(`/api/generate/${name}?${genParams}`); const data = await response.json(); - console.debug(data); - if (data.error) { alert(`Error: ${data.error}`); return; } + if (otavaEnabled) { + try { + data.otavaMulti = await runOtavaForEnabled(data.data); + } catch (e) { + console.error('Otava compare failed:', e); + data.otavaMulti = new Map(); + } + // Temporary back-compat: legacy single-otava consumers still read + // data.otava. Mirror the primary algo so the current chart, accuracy + // table, and detected table keep rendering until they migrate. + const primary = primaryOtavaAlgorithm(); + const primaryRes = data.otavaMulti.get(primary); + if (primaryRes) { + data.otava = { + detected_change_points: primaryRes.change_points || [], + detected_indices: primaryRes.indices || [], + count: primaryRes.count || 0, + }; + } else { + data.otava = { detected_change_points: [], detected_indices: [], count: 0 }; + } + } else { + data.otavaMulti = new Map(); + data.otava = { detected_change_points: [], detected_indices: [], count: 0 }; + } + updateChart(data); updateStats(data); updateAccuracyMetrics(data); updateComparisonTables(data); - // Hide multi-chart view when generating single multiChartContainer.classList.add('hidden'); document.querySelector('.stacked-charts-container').classList.remove('hidden'); document.querySelector('.chart-legend').classList.remove('hidden'); statsSection.classList.remove('hidden'); accuracyMetrics.classList.remove('hidden'); cpDetail.classList.remove('hidden'); - } catch (error) { console.error('Failed to generate data:', error); } finally { @@ -1407,6 +1420,31 @@ function classifyDetections(detectedIndices, groundTruthIndices) { return { exactMatches, closeMatches, tp, cm, fp }; } +/** Show or hide the discreet error notice near the Otava controls panel. + * Reads each enabled algo's `error` field from /api/compare and aggregates + * them into a small inline list. Hidden when there are no errors. */ +function renderOtavaErrorNotice(otavaMulti) { + const el = document.getElementById('otava-error-notice'); + if (!el) return; + const errors = []; + if (otavaMulti) { + for (const algo of ALGO_ORDER) { + const res = otavaMulti.get(algo); + if (res && res.error) errors.push({ algo, error: res.error }); + } + } + if (errors.length === 0) { + el.classList.add('hidden'); + el.innerHTML = ''; + return; + } + const items = errors.map(({ algo, error }) => + `<li><strong>${ALGO_LABELS[algo]}:</strong> ${error}</li>` + ).join(''); + el.classList.remove('hidden'); + el.innerHTML = `<strong>Algorithm error:</strong><ul>${items}</ul>`; +} + // Update the stacked charts - one per enabled analysis method function updateChart(data) { // Destroy existing charts @@ -1416,6 +1454,8 @@ function updateChart(data) { // Clear container stackedChartsContainer.innerHTML = ''; + renderOtavaErrorNotice(data.otavaMulti); + // Prepare data const labels = data.data.map((_, i) => i); const values = data.data; @@ -1570,34 +1610,74 @@ function updateChart(data) { // Create Otava chart if enabled if (isOtavaEnabled()) { - const { tp: otavaTp, cm: otavaCm, fp: otavaFp, exactMatches: otavaExact, closeMatches: otavaClose } = otavaClassification; - const canvas = createChartContainer('otava', 'Otava Analysis', '#2563eb', otavaTp, otavaCm, otavaFp); + const enabledAlgos = ALGO_ORDER.filter(a => (data.otavaMulti || new Map()).has(a)); + + // Per-algo classification (computed once, reused for border colors + // and aggregated tp/cm/fp counts in the chart header). + const perAlgoCls = new Map(); + let totalTp = 0, totalCm = 0, totalFp = 0; + for (const algo of enabledAlgos) { + const res = data.otavaMulti.get(algo); + const cls = classifyDetections(res.indices || [], groundTruthIndices); + perAlgoCls.set(algo, cls); + totalTp += cls.tp; + totalCm += cls.cm; + totalFp += cls.fp; + } + + const canvas = createChartContainer('otava', 'Otava Analysis', '#2563eb', totalTp, totalCm, totalFp); + + // Per-algo color swatches in the Otava chart header. + const otavaHeader = canvas.previousElementSibling; + if (otavaHeader && otavaHeader.classList.contains('stacked-chart-header')) { + const swatches = document.createElement('span'); + swatches.className = 'otava-algo-swatches'; + swatches.innerHTML = enabledAlgos.map(algo => + `<span class="swatch"><span class="algo-dot algo-dot-${algo}"></span>${ALGO_LABELS[algo]}</span>` + ).join(''); + otavaHeader.appendChild(swatches); + } + const ctx = canvas.getContext('2d'); - const otavaPointColors = values.map((_, i) => { - if (detectedIndices.includes(i)) { - if (otavaExact.has(i)) return '#f87171'; // TP - red - if (otavaClose.has(i)) return '#fde047'; // CM - yellow - return '#f97316'; // FP - orange - } - return 'transparent'; - }); - const otavaPointBorders = values.map((_, i) => { - if (detectedIndices.includes(i)) { - if (otavaExact.has(i)) return '#ef4444'; - if (otavaClose.has(i)) return '#eab308'; - return '#ea580c'; - } - return 'transparent'; - }); - const otavaPointRadii = values.map((_, i) => detectedIndices.includes(i) ? 6 : 0); - const otavaPointStyles = values.map((_, i) => { - if (detectedIndices.includes(i)) { - if (otavaExact.has(i)) return 'circle'; // TP - if (otavaClose.has(i)) return 'rectRot'; // CM - diamond - return 'triangle'; // FP - } - return 'circle'; + // Vertical-stacking offset in Y-axis data units (2% of the visible range per row). + const yMin = parseFloat(yMinInput.value); + const yMax = parseFloat(yMaxInput.value); + const yRange = Math.max(1, yMax - yMin); + const offsetUnit = yRange * 0.02; + + const otavaDatasets = enabledAlgos.map((algo, algoIdx) => { + const res = data.otavaMulti.get(algo); + const indices = new Set(res.indices || []); + const cls = perAlgoCls.get(algo); + const exactMatches = cls.exactMatches; + const closeMatches = cls.closeMatches; + + // Symmetric vertical fan: algoIdx 0..N-1, centered around 0. + const center = (enabledAlgos.length - 1) / 2; + const yOffset = (algoIdx - center) * offsetUnit; + + const pointData = values.map((v, i) => indices.has(i) ? (v + yOffset) : null); + + const pointBorderColors = values.map((_, i) => { + if (!indices.has(i)) return 'transparent'; + if (exactMatches.has(i)) return ALGO_COLORS[algo]; // TP: blend with fill + if (closeMatches.has(i)) return '#f59e0b'; // CM: orange + return '#ef4444'; // FP: red + }); + + return { + label: `Otava (${algo})`, + data: pointData, + showLine: false, + fill: false, + pointBackgroundColor: ALGO_COLORS[algo], + pointBorderColor: pointBorderColors, + pointBorderWidth: 2, + pointRadius: 5, + pointHoverRadius: 7, + pointStyle: 'circle', + }; }); const isLast = enabledMethods[enabledMethods.length - 1] === 'otava'; @@ -1605,21 +1685,21 @@ function updateChart(data) { type: 'line', data: { labels: labels, - datasets: [{ - label: data.generator, - data: values, - borderColor: '#94a3b8', - backgroundColor: 'rgba(148, 163, 184, 0.1)', - borderWidth: 1.5, - fill: true, - tension: 0, - pointBackgroundColor: otavaPointColors, - pointBorderColor: otavaPointBorders, - pointBorderWidth: 1.5, - pointRadius: otavaPointRadii, - pointHoverRadius: 8, - pointStyle: otavaPointStyles, - }] + datasets: [ + { + label: data.generator, + data: values, + borderColor: '#94a3b8', + backgroundColor: 'rgba(148, 163, 184, 0.1)', + borderWidth: 1.5, + fill: true, + tension: 0, + pointBackgroundColor: 'transparent', + pointBorderColor: 'transparent', + pointRadius: 0, + }, + ...otavaDatasets, + ] }, options: getChartOptions(createAnnotations(), isLast) }); @@ -1962,13 +2042,24 @@ function updateChart(data) { `; } - // Store all results for accuracy metrics display - data._methodResults = { - otava: isOtavaEnabled() ? { - name: 'Otava', - classification: otavaClassification, - detectedIndices: detectedIndices - } : null, + // Store all results for accuracy metrics display. One Otava entry per + // enabled algorithm so the accuracy table renders a row per algorithm. + data._methodResults = {}; + if (data.otavaMulti && data.otavaMulti.size > 0) { + for (const algo of ALGO_ORDER) { + const res = data.otavaMulti.get(algo); + if (!res) continue; + const indices = res.indices || []; + const classification = classifyDetections(indices, groundTruthIndices); + data._methodResults[`otava_${algo}`] = { + name: `Otava (${algo})`, + classification, + detectedIndices: indices, + algo, + }; + } + } + Object.assign(data._methodResults, { ma: runMa ? { name: 'Moving Average', classification: maClassification, @@ -1993,11 +2084,30 @@ function updateChart(data) { name: 'Std Dev', classification: stdDevClassification, detectedIndices: stdDevDetectedIndices - } : null - }; + } : null, + }); data._groundTruthCount = groundTruthIndices.length; } +// Render per-algorithm Otava detected counts +function renderOtavaDetectedStat(otavaMulti) { + const algos = ALGO_ORDER.filter(a => (otavaMulti || new Map()).has(a)); + if (algos.length === 0) return '-'; + if (algos.length === 1) { + const algo = algos[0]; + const count = otavaMulti.get(algo).count || 0; + return String(count); + } + const segments = algos.map(algo => { + const count = otavaMulti.get(algo).count || 0; + return `<span class="otava-stat-segment"> + <span class="algo-dot algo-dot-${algo}"></span> + <span>${ALGO_LABELS[algo]} ${count}</span> + </span>`; + }); + return `<span class="otava-stat-segments">${segments.join('')}</span>`; +} + // Update statistics display function updateStats(data) { const values = data.data; @@ -2013,7 +2123,7 @@ function updateStats(data) { const allCPs = data.ground_truth?.change_points || data.change_points || []; const trueChangePointCount = allCPs.filter(cp => cp.type !== 'outlier').length; statCpTruth.textContent = trueChangePointCount; - statCpDetected.textContent = data.otava?.count ?? '-'; + statCpDetected.innerHTML = renderOtavaDetectedStat(data.otavaMulti); } // Update accuracy metrics display @@ -2032,7 +2142,10 @@ function updateAccuracyMetrics(data) { } // Add a row for each enabled method - const methodOrder = ['otava', 'ma', 'boundary', 'threshold', 'slidingWindow', 'stdDev']; + const methodOrder = [ + 'otava_split', 'otava_orig', 'otava_deterministic', + 'ma', 'boundary', 'threshold', 'slidingWindow', 'stdDev', + ]; let hasAnyMethod = false; for (const methodKey of methodOrder) { @@ -2084,55 +2197,121 @@ function updateComparisonTables(data) { detectedTableBody.innerHTML = ''; const groundTruth = data.ground_truth?.change_points || data.change_points || []; - const detected = data.otava?.detected_change_points || []; - const matchedPairs = data.accuracy?.matched_pairs || []; + const groundTruthIndices = groundTruth + .filter(cp => cp.type !== 'outlier') + .map(cp => cp.index); - const matchedTruthIndices = new Set(matchedPairs.map(p => p.ground_truth)); - const matchedDetectedIndices = new Set(matchedPairs.map(p => p.detected)); + const otavaMulti = data.otavaMulti || new Map(); + const enabledAlgos = ALGO_ORDER.filter(a => otavaMulti.has(a)); - // Ground truth table + // ── Detected table head: rebuild per-algo columns. ────────────── + const head = document.getElementById('detected-table-head-row'); + head.innerHTML = '<th>Index</th><th>Mean Before</th><th>Mean After</th>'; + enabledAlgos.forEach(algo => { + const th = document.createElement('th'); + th.innerHTML = `<span class="algo-dot algo-dot-${algo}"></span>${ALGO_LABELS[algo]}`; + head.appendChild(th); + }); + + // ── Per-algo classifications (computed once, reused below). ───── + const perAlgoCls = new Map(); + for (const algo of enabledAlgos) { + const res = otavaMulti.get(algo); + perAlgoCls.set(algo, classifyDetections(res.indices || [], groundTruthIndices)); + } + + // ── Detected table body: one row per detected index. ──────────── + // Build a per-index map: index -> { perAlgo: { algo: cp }, anyCp: cp } + const byIndex = new Map(); + for (const algo of enabledAlgos) { + const res = otavaMulti.get(algo); + for (const cp of (res.change_points || [])) { + if (!byIndex.has(cp.index)) byIndex.set(cp.index, { perAlgo: {}, anyCp: cp }); + byIndex.get(cp.index).perAlgo[algo] = cp; + } + } + + if (byIndex.size === 0) { + const row = document.createElement('tr'); + const colspan = 3 + enabledAlgos.length; + row.innerHTML = `<td colspan="${colspan || 3}" class="empty-message">No change points detected by Otava</td>`; + detectedTableBody.appendChild(row); + } else { + const sortedIndices = [...byIndex.keys()].sort((a, b) => a - b); + for (const idx of sortedIndices) { + const { perAlgo, anyCp } = byIndex.get(idx); + const row = document.createElement('tr'); + + // Index, mean_before, mean_after — taken from the first algo (in + // canonical order) that detected this index, since those values + // are a function of position and agree across algos. + const firstAlgo = enabledAlgos.find(a => perAlgo[a]); + const cpRef = firstAlgo ? perAlgo[firstAlgo] : anyCp; + row.innerHTML = ` + <td><strong>${idx}</strong></td> + <td>${cpRef.mean_before != null ? cpRef.mean_before.toFixed(2) : '-'}</td> + <td>${cpRef.mean_after != null ? cpRef.mean_after.toFixed(2) : '-'}</td> + `; + + // Per-algo cells. + for (const algo of enabledAlgos) { + const td = document.createElement('td'); + const cp = perAlgo[algo]; + if (!cp) { + td.innerHTML = '<span class="pvalue-empty">—</span>'; + } else { + const cls = perAlgoCls.get(algo); + let kls = 'pvalue-fp'; + if (cls.exactMatches.has(idx)) kls = 'pvalue-tp'; + else if (cls.closeMatches.has(idx)) kls = 'pvalue-cm'; + const p = cp.pvalue != null ? cp.pvalue.toExponential(2) : '—'; + td.innerHTML = `<span class="${kls}">${p}</span>`; + } + row.appendChild(td); + } + detectedTableBody.appendChild(row); + } + } + + // ── Ground-truth table body with per-algo "Matched by" dots. ──── if (groundTruth.length === 0) { const row = document.createElement('tr'); row.innerHTML = '<td colspan="4" class="empty-message">No ground truth change points</td>'; truthTableBody.appendChild(row); } else { groundTruth.forEach(cp => { - const matched = matchedTruthIndices.has(cp.index); - const matchInfo = matchedPairs.find(p => p.ground_truth === cp.index); + const matched = matchedByList(cp.index, enabledAlgos, perAlgoCls, otavaMulti); const row = document.createElement('tr'); row.innerHTML = ` <td><strong>${cp.index}</strong></td> <td>${cp.type}</td> <td>${cp.description || '-'}</td> - <td class="${matched ? 'status-matched' : 'status-missed'}"> - ${matched ? `Yes (at ${matchInfo.detected})` : 'No'} - </td> + <td>${matched}</td> `; truthTableBody.appendChild(row); }); } +} - // Detected table - if (detected.length === 0) { - const row = document.createElement('tr'); - row.innerHTML = '<td colspan="5" class="empty-message">No change points detected by Otava</td>'; - detectedTableBody.appendChild(row); - } else { - detected.forEach(cp => { - const isTP = matchedDetectedIndices.has(cp.index); - const row = document.createElement('tr'); - row.innerHTML = ` - <td><strong>${cp.index}</strong></td> - <td>${cp.mean_before.toFixed(2)}</td> - <td>${cp.mean_after.toFixed(2)}</td> - <td>${cp.pvalue.toExponential(2)}</td> - <td class="${isTP ? 'status-tp' : 'status-fp'}"> - ${isTP ? 'True Positive' : 'False Positive'} - </td> - `; - detectedTableBody.appendChild(row); +/** For a single GT index, return HTML showing which algos matched and how. */ +function matchedByList(gtIndex, enabledAlgos, perAlgoCls, otavaMulti) { + if (enabledAlgos.length === 0) return '<span class="matched-entry-miss">—</span>'; + const parts = enabledAlgos.map(algo => { + const cls = perAlgoCls.get(algo); + const res = otavaMulti.get(algo); + const det = (res.indices || []).find(d => { + if (cls.exactMatches.has(d) && d === gtIndex) return true; + return cls.closeMatches.has(d) && Math.abs(d - gtIndex) <= CLOSE_MATCH_TOLERANCE; }); - } + const dot = `<span class="algo-dot algo-dot-${algo}"></span>`; + const label = ALGO_LABELS[algo]; + if (det == null) { + return `<span class="matched-entry matched-entry-miss">${dot}${label} —</span>`; + } + const tag = (det === gtIndex) ? `${det}` : `${det} (CM)`; + return `<span class="matched-entry">${dot}${label} ${tag}</span>`; + }); + return `<span class="matched-by-list">${parts.join('')}</span>`; } // Show all patterns with Otava comparison diff --git a/src/otava_test_data/web/templates/index.html b/src/otava_test_data/web/templates/index.html index 9d35fc1..761c458 100644 --- a/src/otava_test_data/web/templates/index.html +++ b/src/otava_test_data/web/templates/index.html @@ -221,6 +221,8 @@ </div> </div> + <div id="otava-error-notice" class="otava-error-notice hidden"></div> + <!-- Method tutorial (hidden by default) --> <div class="method-tutorial hidden" id="otava-tutorial"> <p class="method-explanation"></p>
