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>

Reply via email to