diff --git a/README.md b/README.md index afea536..5ab2449 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,16 @@ inv web-start Then open http://127.0.0.1:8100 in your browser. +### Compare algorithms on a real dataset + +A third mode, **Dataset**, lets you load a bundled or pasted time series and +see which change points each Otava algorithm variant +(`compute_change_points`, `compute_change_points_orig`, +`compute_change_points_deterministic`) detects on the same data. The TigerBeetle +benchmark dataset ships as the default preset. The Otava analysis panel now +exposes the same algorithm checkboxes in all three modes. See +`docs/visualizer.md` for details. + ## Installation ```bash diff --git a/docs/index.md b/docs/index.md index 55df55c..98f1799 100644 --- a/docs/index.md +++ b/docs/index.md @@ -105,6 +105,13 @@ otava-gen list otava-gen info step_function ``` +## Compare algorithms on a real dataset + +A third mode, **Dataset**, lets you load a bundled or pasted time series and +see which change points each Otava algorithm variant detects on the same data. +The TigerBeetle benchmark dataset ships as the default preset. See the +[Web Visualizer](visualizer.md) doc for details. + ## Contents ```{toctree} diff --git a/docs/visualizer.md b/docs/visualizer.md index 617f668..bae2143 100644 --- a/docs/visualizer.md +++ b/docs/visualizer.md @@ -355,3 +355,72 @@ The visualizer calculates these metrics when comparing Otava's results to ground ## Show All Graphs Click "Show All Graphs" to run analysis on all generators simultaneously. This produces a grid of charts showing how well the analysis methods perform across different types of change point patterns, allowing you to quickly compare detection accuracy across pattern types. + +## Dataset Mode — compare algorithms on a real series + +The mode toggle in the top section has three options: **Single Pattern**, **Mix +Patterns**, and **Dataset**. Dataset mode replaces the synthetic generator grid +with a dataset picker and a "Custom (paste below)" textarea so you can load a +real time series and see where each Otava algorithm variant places change +points on it. + +Ground-truth-only UI (accuracy metrics, comparison tables, chart legend) is +hidden in Dataset mode because real data doesn't come with known change points. + +### Bundled datasets + +| Name | Length | Description | +|------|--------|-------------| +| `tigerbeetle` | 365 | Same series used by `apache/otava`'s `perf/perf_test.py`. Has a couple of distinctive ups and downs, an anomalous drop, then an upward slope, then normal variance. | + +Add more presets by dropping a file in `src/otava_test_data/datasets/` and +registering it in `datasets/__init__.py::DATASETS`. + +### Algorithm checkboxes + +The Otava Analysis panel exposes a checkbox per algorithm variant in all three +modes: + +| Key | Otava function | CLI flag | +|-----|----------------|----------| +| `split` | `compute_change_points` | (default) | +| `orig` | `compute_change_points_orig` | `--orig-edivisive` | +| `deterministic` | `compute_change_points_deterministic` | `--deterministic-edivisive` ([PR](https://github.com/apache/otava/pull/154)) | + +If `compute_change_points_deterministic` isn't importable in the installed +otava version, the checkbox is disabled with a "(not in installed otava)" +indicator next to it. + +### Custom data + +Pick "Custom (paste below)" from the source dropdown and paste a series as +either a JSON array (`[1.2, 3.4, ...]`) or whitespace/comma-separated numbers. +Non-numeric tokens are filtered out and the UI tells you how many were dropped. + +### HTTP endpoints + +- `GET /api/datasets` — bundled-preset metadata. +- `GET /api/datasets/{name}` — one preset's series + metadata. +- `GET /api/algorithms` — change-point algorithms exposed by the installed + otava version. +- `POST /api/compare?window_len=...&max_pvalue=...&min_magnitude=...` — + run selected algorithms on a series. Body: + ```json + {"data": [1.2, 3.4, ...], "algorithms": ["split", "orig"]} + ``` + Response: + ```json + { + "results": { + "split": {"indices": [15, 71, ...], "count": 8}, + "orig": {"indices": [15, 71, ...], "count": 5} + }, + "parameters": {"window_len": 50, "max_pvalue": 0.001, "min_magnitude": 0.0} + } + ``` + If `algorithms` is omitted, every available algorithm runs. + +The synthetic-pattern endpoints `/api/generate/{name}` and +`/api/analyze/{name}` also accept an `otava_algorithm` query parameter +(`split` | `orig` | `deterministic`) so synthetic-pattern flows can pick an +algorithm variant too. diff --git a/pyproject.toml b/pyproject.toml index 537c8d7..80fe338 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dev = [ "pytest-cov>=4.0.0", "invoke>=2.0.0", "ruff>=0.1.0", + "httpx>=0.27.0", # required by fastapi.testclient ] docs = [ "sphinx>=7.0.0", diff --git a/src/otava_test_data/datasets/__init__.py b/src/otava_test_data/datasets/__init__.py new file mode 100644 index 0000000..51c6009 --- /dev/null +++ b/src/otava_test_data/datasets/__init__.py @@ -0,0 +1,37 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Real-world performance datasets bundled for algorithm comparison.""" + +from otava_test_data.datasets.tigerbeetle import TIGERBEETLE + +DATASETS = { + TIGERBEETLE["name"]: TIGERBEETLE, +} + + +def list_datasets() -> list[dict]: + """Return metadata for every bundled dataset (without the series itself).""" + return [ + {"name": ds["name"], "title": ds["title"], "description": ds["description"]} + for ds in DATASETS.values() + ] + + +def get_dataset(name: str) -> dict | None: + """Return the full dataset entry (series + metadata) or None if unknown.""" + return DATASETS.get(name) diff --git a/src/otava_test_data/datasets/tigerbeetle.py b/src/otava_test_data/datasets/tigerbeetle.py new file mode 100644 index 0000000..e32970f --- /dev/null +++ b/src/otava_test_data/datasets/tigerbeetle.py @@ -0,0 +1,75 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +TigerBeetle benchmark series — same values used by apache/otava's +perf/perf_test.py for algorithm performance benchmarking. + +Source: https://github.com/apache/otava/blob/master/perf/perf_test.py +""" + +TIGERBEETLE_SERIES = [ + 26705, 26475, 26641, 26806, 26835, 26911, 26564, 26812, 26874, 26682, + 15672, 26745, 26460, 26977, 26851, 23412, 23547, 23674, 23519, 23670, + 23662, 23462, 23750, 23717, 23524, 23588, 23687, 23793, 23937, 23715, + 23570, 23730, 23690, 23699, 23670, 23860, 23988, 23652, 23681, 23798, + 23728, 23604, 23523, 23412, 23685, 23773, 23771, 23718, 23409, 23739, + 23674, 23597, 23682, 23680, 23711, 23660, 23990, 23938, 23742, 23703, + 23536, 24363, 24414, 24483, 24509, 24944, 24235, 24560, 24236, 24667, + 24730, 28346, 28437, 28436, 28057, 28217, 28456, 28427, 28398, 28250, + 28331, 28222, 28726, 28578, 28345, 28274, 28514, 28590, 28449, 28305, + 28411, 28788, 28404, 28821, 28580, 27483, 26805, 27487, 27124, 26898, + 27295, 26951, 27312, 27660, 27154, 27050, 26989, 27193, 27503, 27326, + 27375, 27513, 27057, 27421, 27574, 27609, 27123, 27824, 27644, 27394, + 27836, 27949, 27702, 27457, 27272, 28207, 27802, 27516, 27586, 28005, + 27768, 28543, 28237, 27915, 28437, 28342, 27733, 28296, 28524, 28687, + 28258, 28611, 29360, 28590, 29641, 28965, 29474, 29256, 28611, 28205, + 28539, 27962, 28398, 28509, 28240, 28592, 28102, 28461, 28578, 28669, + 28507, 28535, 28226, 28536, 28561, 28087, 27953, 28398, 28007, 28518, + 28337, 28242, 28607, 28545, 28514, 28377, 28010, 28412, 28633, 28576, + 28195, 28637, 28724, 28466, 28287, 28719, 28425, 28860, 28842, 28604, + 28327, 28216, 28946, 28918, 29287, 28725, 29148, 29541, 29137, 29628, + 29087, 28612, 29154, 29108, 28884, 29234, 28695, 28969, 28809, 28695, + 28634, 28916, 29852, 29389, 29757, 29531, 29363, 29251, 29552, 29561, + 29046, 29795, 29022, 29395, 28921, 29739, 29257, 29455, 29376, 29528, + 28909, 29492, 28984, 29621, 29026, 29457, 29102, 29114, 28924, 29162, + 29259, 29554, 29616, 29211, 29367, 29460, 28836, 29645, 29586, 28848, + 29324, 28969, 29150, 29243, 29081, 29312, 28923, 29272, 29117, 29072, + 29529, 29737, 29652, 29612, 29856, 29012, 30402, 29969, 29309, 29439, + 29285, 29421, 29023, 28772, 29692, 29416, 29267, 29542, 29904, 30045, + 29739, 29945, 29141, 29163, 29765, 29197, 29441, 28910, 29504, 29614, + 29643, 29506, 29420, 29672, 29432, 29784, 29888, 29309, 29247, 29816, + 29254, 29813, 29451, 29382, 29618, 28558, 29845, 29499, 29283, 29184, + 29246, 28790, 29952, 29145, 29415, 30437, 29227, 29605, 29859, 29156, + 29807, 29406, 29734, 29861, 29140, 29983, 29832, 29919, 29896, 29991, + 29266, 29001, 29459, 29548, 29310, 29042, 29303, 29894, 29091, 29018, + 29537, 29614, 29180, 29736, 29500, 29218, 29581, 28906, 28542, 29306, + 28987, 29878, 28865, 30272, 29707, 29662, 29815, 30492, 29347, 30096, + 29054, 30238, 28813, 31895, 28915, +] + +TIGERBEETLE = { + "name": "tigerbeetle", + "title": "TigerBeetle", + "description": ( + "TigerBeetle dataset used for demo purposes at Nyrkiö and as the " + "performance benchmark series in apache/otava's perf_test.py. " + "Contains a couple of distinctive ups and downs, an anomalous drop, " + "then an upward slope, with the rest as normal variance." + ), + "series": TIGERBEETLE_SERIES, +} diff --git a/src/otava_test_data/tests/test_compare_api.py b/src/otava_test_data/tests/test_compare_api.py new file mode 100644 index 0000000..f818118 --- /dev/null +++ b/src/otava_test_data/tests/test_compare_api.py @@ -0,0 +1,161 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the /api/datasets and /api/compare endpoints.""" + +from fastapi.testclient import TestClient + +from otava_test_data.web.main import app + +client = TestClient(app) + + +def test_list_datasets(): + r = client.get("/api/datasets") + assert r.status_code == 200 + body = r.json() + assert "datasets" in body + names = [d["name"] for d in body["datasets"]] + assert "tigerbeetle" in names + + +def test_get_tigerbeetle(): + r = client.get("/api/datasets/tigerbeetle") + assert r.status_code == 200 + body = r.json() + assert body["name"] == "tigerbeetle" + assert len(body["series"]) == 365 + + +def test_get_unknown_dataset_404(): + r = client.get("/api/datasets/nope") + assert r.status_code == 404 + + +def test_list_algorithms_includes_split(): + r = client.get("/api/algorithms") + assert r.status_code == 200 + body = r.json() + assert "split" in body["algorithms"] + # split must be available because apache-otava is a hard dependency. + assert body["algorithms"]["split"]["available"] is True + + +def test_compare_runs_split_on_tigerbeetle(): + series = client.get("/api/datasets/tigerbeetle").json()["series"] + r = client.post( + "/api/compare?max_pvalue=0.001", + json={"data": series, "algorithms": ["split"]}, + ) + assert r.status_code == 200 + body = r.json() + assert "split" in body["results"] + assert "indices" in body["results"]["split"] + # TigerBeetle has clear change points — split should detect at least one. + assert body["results"]["split"]["indices"] + + +def test_compare_rejects_empty_data(): + r = client.post("/api/compare", json={"data": [], "algorithms": ["split"]}) + assert r.status_code == 400 + + +def test_compare_rejects_unknown_algorithm(): + """Unknown algorithm names should fail validation, not return 200 with an inline error.""" + r = client.post( + "/api/compare", + json={"data": [1.0, 2.0, 1.0, 2.0, 1.0, 2.0], "algorithms": ["nonexistent"]}, + ) + assert r.status_code == 422 + + +def test_compare_default_runs_all_available_algorithms(): + """When `algorithms` is omitted, every available algorithm in ALGORITHMS runs.""" + available = { + name for name, info in client.get("/api/algorithms").json()["algorithms"].items() + if info["available"] + } + r = client.post( + "/api/compare", + json={"data": [1.0, 1.1, 1.0, 1.2, 5.0, 5.1, 5.0, 5.2, 5.3, 5.1]}, + ) + assert r.status_code == 200 + assert set(r.json()["results"].keys()) == available + + +def test_compare_accepts_floats_and_round_trips_through_json(): + """Float series shouldn't blow up at JSON serialization.""" + series = [1.5, 2.5, 1.7, 2.3, 1.4, 3.9, 4.1, 4.0, 4.2, 4.3, 4.1] + r = client.post("/api/compare", json={"data": series, "algorithms": ["split"]}) + assert r.status_code == 200 + body = r.json() + # Indices must be JSON-serializable plain ints, not numpy types. + for idx in body["results"]["split"]["indices"]: + assert isinstance(idx, int) + + +def test_index_exposes_dataset_mode(): + """The main page should expose the dataset-mode button and section.""" + r = client.get("/") + assert r.status_code == 200 + assert 'id="mode-dataset-btn"' in r.text + assert 'id="dataset-section"' in r.text + assert 'otava-algo-checkbox' in r.text + + +def test_generate_with_otava_algorithm_orig(): + """The /api/generate endpoint should accept otava_algorithm=orig.""" + r = client.get( + "/api/generate/step_function", + params={ + "length": 100, "seed": 42, + "run_otava": True, "otava_algorithm": "orig", + "max_pvalue": 0.001, + }, + ) + assert r.status_code == 200 + body = r.json() + assert "otava" in body + # No error path expected here. + assert "error" not in body["otava"] + assert "parameters" in body["otava"], body["otava"] + assert body["otava"]["parameters"]["algorithm"] == "orig" + + +def test_generate_rejects_unknown_otava_algorithm(): + r = client.get( + "/api/generate/step_function", + params={"length": 50, "seed": 42, "otava_algorithm": "nonexistent"}, + ) + assert r.status_code == 422 + + +def test_analyze_accepts_otava_algorithm(): + """`/api/analyze` documents the otava_algorithm param — verify it actually works.""" + r = client.get( + "/api/analyze/step_function", + params={ + "length": 100, "seed": 42, + "otava_algorithm": "orig", + "max_pvalue": 0.001, + }, + ) + assert r.status_code == 200 + body = r.json() + assert "otava" in body + assert "error" not in body["otava"] + assert body["otava"]["parameters"]["algorithm"] == "orig" diff --git a/src/otava_test_data/tests/test_datasets.py b/src/otava_test_data/tests/test_datasets.py new file mode 100644 index 0000000..956bbe0 --- /dev/null +++ b/src/otava_test_data/tests/test_datasets.py @@ -0,0 +1,53 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the bundled real-world datasets.""" + + +from otava_test_data.datasets import DATASETS, get_dataset, list_datasets + + +def test_list_includes_tigerbeetle(): + names = [d["name"] for d in list_datasets()] + assert "tigerbeetle" in names + + +def test_get_unknown_returns_none(): + assert get_dataset("not-a-dataset") is None + + +def test_tigerbeetle_metadata_and_shape(): + ds = get_dataset("tigerbeetle") + assert ds is not None + assert ds["name"] == "tigerbeetle" + assert ds["title"] + assert ds["description"] + assert isinstance(ds["series"], list) + # Same length as the upstream apache/otava perf_test.py series. + assert len(ds["series"]) == 365 + assert all(isinstance(v, (int, float)) for v in ds["series"]) + + +def test_list_omits_series_payload(): + # list_datasets() should not echo the full series — only metadata. + for d in list_datasets(): + assert "series" not in d + + +def test_registry_keyed_by_name(): + for name, ds in DATASETS.items(): + assert ds["name"] == name diff --git a/src/otava_test_data/web/main.py b/src/otava_test_data/web/main.py index 9771d2b..a223437 100644 --- a/src/otava_test_data/web/main.py +++ b/src/otava_test_data/web/main.py @@ -7,7 +7,7 @@ import json from pathlib import Path -from typing import Any +from typing import Any, Literal import numpy as np from fastapi import FastAPI, Request, Query, Body @@ -16,6 +16,10 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates + +# Algorithm name aliases — Literal type used both by query params and request bodies. +AlgorithmName = Literal["split", "orig", "deterministic"] + # Otava imports - optional dependency try: from otava.analysis import compute_change_points @@ -24,6 +28,21 @@ OTAVA_AVAILABLE = False compute_change_points = None +# Optional alternative algorithms — present on newer otava versions / +# https://github.com/apache/otava/pull/154. +# Feature-detected at import time so the /compare UI only offers what works. +try: + from otava.analysis import compute_change_points_orig +except ImportError: + compute_change_points_orig = None + +try: + from otava.analysis import compute_change_points_deterministic +except ImportError: + compute_change_points_deterministic = None + +from otava_test_data.datasets import DATASETS, get_dataset, list_datasets + from otava_test_data.generators.basic import ( constant, noise_normal, @@ -1190,15 +1209,17 @@ def run_otava_analysis( window_len: int = 30, max_pvalue: float = 0.05, min_magnitude: float = 0.0, + algorithm: str = "split", ) -> dict[str, Any]: """ - Run Otava change point detection on data. + Run an Otava change point detection algorithm on data. Args: - data: Time series data as numpy array. - window_len: Minimum window length for detection. + data: Time series data. + window_len: Window length (only meaningful for `split`). max_pvalue: Maximum p-value threshold for significance. min_magnitude: Minimum magnitude of change to report. + algorithm: Which algorithm to run — 'split' (default), 'orig', or 'deterministic'. Returns: Dictionary with detected change points and metrics. @@ -1211,36 +1232,60 @@ def run_otava_analysis( "count": 0, } - try: - result = compute_change_points( - data, - window_len=window_len, - max_pvalue=max_pvalue, - min_magnitude=min_magnitude, - ) + if algorithm == "split": + fn, kwargs = compute_change_points, { + "window_len": window_len, "max_pvalue": max_pvalue, + "min_magnitude": min_magnitude, + } + elif algorithm == "orig": + fn, kwargs = compute_change_points_orig, {"max_pvalue": max_pvalue} + elif algorithm == "deterministic": + fn, kwargs = compute_change_points_deterministic, { + "max_pvalue": max_pvalue, "min_magnitude": min_magnitude, + } + else: + return { + "error": f"unknown algorithm: {algorithm}", + "detected_change_points": [], "detected_indices": [], "count": 0, + } - # Result is a tuple, first element is list of ChangePoint objects + if fn is None: + return { + "error": f"{algorithm} not available in installed otava version", + "detected_change_points": [], "detected_indices": [], "count": 0, + } + + try: + # otava.analysis.compute_change_points crashes with an unmessaged ValueError + # on numpy arrays for some window/data combinations; a plain list of + # Python floats sidesteps it. Match the coercion done in /api/compare. + series = [float(v) for v in data] + result = fn(series, **kwargs) change_points_list = result[0] if isinstance(result, tuple) else result detected = [] for cp in change_points_list: - detected.append({ - "index": int(cp.index), # Convert numpy.int64 to int - "mean_before": float(cp.stats.mean_1), - "mean_after": float(cp.stats.mean_2), - "std_before": float(cp.stats.std_1), - "std_after": float(cp.stats.std_2), - "pvalue": float(cp.stats.pvalue), - }) + entry = {"index": int(cp.index)} + stats = getattr(cp, "stats", None) + if stats is not None: + for key in ("mean_1", "mean_2", "std_1", "std_2", "pvalue"): + val = getattr(stats, key, None) + if val is not None: + out_key = { + "mean_1": "mean_before", "mean_2": "mean_after", + "std_1": "std_before", "std_2": "std_after", + "pvalue": "pvalue", + }[key] + entry[out_key] = float(val) + detected.append(entry) return { "detected_change_points": detected, "detected_indices": [cp["index"] for cp in detected], "count": len(detected), "parameters": { - "window_len": window_len, - "max_pvalue": max_pvalue, - "min_magnitude": min_magnitude, + "window_len": window_len, "max_pvalue": max_pvalue, + "min_magnitude": min_magnitude, "algorithm": algorithm, }, } @@ -1392,6 +1437,7 @@ def timeseries_to_dict( window_len=params.get("window_len", 30), max_pvalue=params.get("max_pvalue", 0.05), min_magnitude=params.get("min_magnitude", 0.0), + algorithm=params.get("algorithm", "split"), ) result["otava"] = otava_result @@ -1413,9 +1459,9 @@ def timeseries_to_dict( async def index(request: Request): """Main page with generator visualization.""" return templates.TemplateResponse( + request, "index.html", { - "request": request, "generators": GENERATORS, "default_length": 200, "version": __version__, @@ -1457,6 +1503,7 @@ async def generate_data( length: int = Query(default=200, ge=10, le=2000), seed: int = Query(default=42), run_otava: bool = Query(default=False, description="Run Otava analysis"), + otava_algorithm: AlgorithmName = Query(default="split", description="Otava algorithm to run"), # noqa: B008 window_len: int = Query(default=99999, ge=5, le=100000, description="Otava window length"), max_pvalue: float = Query(default=0.01, ge=0.0, le=1.0, description="Otava max p-value"), tolerance: int = Query(default=5, ge=0, le=50, description="Accuracy tolerance"), @@ -1496,6 +1543,7 @@ async def generate_data( "window_len": window_len, "max_pvalue": max_pvalue, "tolerance": tolerance, + "algorithm": otava_algorithm, } return timeseries_to_dict(ts, include_otava=run_otava, otava_params=otava_params) except Exception as e: @@ -1510,6 +1558,7 @@ async def analyze_with_otava( generator_name: str, length: int = Query(default=200, ge=10, le=2000), seed: int = Query(default=42), + otava_algorithm: AlgorithmName = Query(default="split", description="Otava algorithm to run"), # noqa: B008 window_len: int = Query(default=30, ge=5, le=100, description="Otava window length"), max_pvalue: float = Query(default=0.00001, ge=0.0, le=1.0, description="Otava max p-value"), min_magnitude: float = Query(default=0.0, ge=0, description="Minimum change magnitude"), @@ -1550,6 +1599,7 @@ async def analyze_with_otava( "max_pvalue": max_pvalue, "min_magnitude": min_magnitude, "tolerance": tolerance, + "algorithm": otava_algorithm, } return timeseries_to_dict(ts, include_otava=True, otava_params=otava_params) except Exception as e: @@ -1643,6 +1693,109 @@ async def detect_change_points( ) +# ----- Dataset comparison: presets and multi-algorithm detection ----- + +ALGORITHMS = { + "split": { + "title": "split-edivisive (default)", + "description": ( + "Hunter-style split-merge e-divisive + Welch t-test significance. " + "Otava default (compute_change_points)." + ), + "available": compute_change_points is not None, + }, + "orig": { + "title": "orig-edivisive", + "description": ( + "Original e-divisive with permutation significance test " + "(compute_change_points_orig, --orig-edivisive)." + ), + "available": compute_change_points_orig is not None, + }, + "deterministic": { + "title": "deterministic-edivisive", + "description": ( + "Original e-divisive with deterministic Welch t-test significance " + "(compute_change_points_deterministic, --deterministic-edivisive, " + "https://github.com/apache/otava/pull/154)." + ), + "available": compute_change_points_deterministic is not None, + }, +} + + +def _run_algorithm(name: str, data, window_len: int, max_pvalue: float, + min_magnitude: float) -> dict[str, Any]: + """Adapter from /api/compare's response shape to run_otava_analysis.""" + res = run_otava_analysis( + data, + window_len=window_len, + max_pvalue=max_pvalue, + min_magnitude=min_magnitude, + algorithm=name, + ) + out = {"indices": res.get("detected_indices", []), "count": res.get("count", 0)} + if res.get("error"): + out["error"] = res["error"] + return out + + +class CompareRequest(BaseModel): + """Request body for /api/compare.""" + data: list[float] + algorithms: list[AlgorithmName] | None = None # default: all available + + +@app.get("/api/datasets") +async def get_datasets(): + """List bundled real-world datasets.""" + return {"datasets": list_datasets()} + + +@app.get("/api/datasets/{name}") +async def get_dataset_endpoint(name: str): + """Return one bundled dataset's series + metadata.""" + ds = get_dataset(name) + if ds is None: + return JSONResponse(status_code=404, content={"error": f"unknown dataset: {name}"}) + return ds + + +@app.get("/api/algorithms") +async def list_algorithms(): + """List change-point algorithms exposed by the installed otava package.""" + return {"algorithms": ALGORITHMS} + + +@app.post("/api/compare") +async def compare_algorithms( + request: CompareRequest, + window_len: int = Query(default=50, ge=5, le=100000), + max_pvalue: float = Query(default=0.001, ge=0.0, le=1.0), + min_magnitude: float = Query(default=0.0, ge=0), +): + """Run multiple change-point algorithms on the same series and return all results.""" + if not request.data: + return JSONResponse(status_code=400, content={"error": "No data provided"}) + if not OTAVA_AVAILABLE: + return JSONResponse(status_code=503, content={"error": "apache-otava not installed"}) + + algorithms = request.algorithms or [n for n, a in ALGORITHMS.items() if a["available"]] + # run_otava_analysis handles float coercion internally. + results = { + name: _run_algorithm(name, request.data, window_len, max_pvalue, min_magnitude) + for name in algorithms + } + return { + "results": results, + "parameters": { + "window_len": window_len, + "max_pvalue": max_pvalue, + "min_magnitude": min_magnitude, + }, + } + + def run(): """Run the web server.""" import uvicorn diff --git a/src/otava_test_data/web/static/css/style.css b/src/otava_test_data/web/static/css/style.css index 01cf3dd..dc9c65b 100644 --- a/src/otava_test_data/web/static/css/style.css +++ b/src/otava_test_data/web/static/css/style.css @@ -1843,3 +1843,175 @@ footer a:hover { grid-template-columns: 1fr; } } + +/* ============================================================ + Dataset mode — load a bundled or pasted series and compare + Otava algorithm variants on the same data. + ============================================================ */ + +.dataset-section { + background: #fafafa; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 16px 20px; + margin: 0 0 20px; +} + +.dataset-section.hidden { + display: none; +} + +/* The mode toggle bar sits above the per-mode content. */ +.mode-toggle-section { + margin-bottom: 8px; +} + +.dataset-row { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.dataset-label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 14px; + font-weight: 600; + max-width: 480px; +} + +.dataset-label select { + padding: 6px 8px; + font: inherit; + border: 1px solid #cbd5e1; + border-radius: 4px; + background: #fff; +} + +.dataset-description { + font-size: 13px; + color: #64748b; + margin: 0; + min-height: 1.4em; +} + +.dataset-custom { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 14px; + font-weight: 600; +} + +.dataset-custom.hidden { + display: none; +} + +.dataset-custom textarea { + font-family: ui-monospace, "SF Mono", Monaco, monospace; + font-size: 13px; + padding: 8px; + border: 1px solid #cbd5e1; + border-radius: 4px; + resize: vertical; +} + +.dataset-hint { + margin: 8px 0 0; + font-size: 13px; + color: #475569; +} + +.dataset-status { + margin: 8px 0 0; + padding: 6px 10px; + background: #eef2ff; + border: 1px solid #c7d2fe; + border-radius: 4px; + font-size: 13px; + color: #1e1b4b; +} + +.dataset-status--error { + background: #fef2f2; + border-color: #fecaca; + color: #7f1d1d; +} + +.otava-algo-row .algo-unavail { + margin-left: 4px; + color: #94a3b8; + font-style: italic; +} + +.dataset-results.hidden { + display: none; +} + +.dataset-results h3 { + margin: 16px 0 8px; +} + +.dataset-results-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.dataset-results-table th, +.dataset-results-table td { + border: 1px solid #e2e8f0; + padding: 6px 10px; + text-align: left; + vertical-align: top; +} + +.dataset-results-table th { + background: #f1f5f9; +} + +.dataset-results-table .swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 2px; + margin-right: 8px; + vertical-align: middle; +} + +/* In dataset mode, hide synthetic-data and ground-truth UI. */ +body[data-mode="dataset"] .hide-in-dataset { + display: none !important; +} + +/* Otava algorithm checkbox group (used in the Otava analysis panel). */ +.otava-algos { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px 0 12px; + font-size: 13px; +} + +.otava-algo-row { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + line-height: 1.4; +} + +.otava-algo-row input[type="checkbox"] { + flex-shrink: 0; +} + +.otava-algo-row input[disabled] { + cursor: not-allowed; +} + +.algo-hint { + color: #64748b; + font-size: 11px; +} diff --git a/src/otava_test_data/web/static/js/app.js b/src/otava_test_data/web/static/js/app.js index 8054e4f..134b241 100644 --- a/src/otava_test_data/web/static/js/app.js +++ b/src/otava_test_data/web/static/js/app.js @@ -12,14 +12,33 @@ let generatorTileCharts = {}; // Mini charts for generator tiles let analysisMethods = {}; // Tutorial content for analysis methods let tutorialVisible = false; // Track tutorial panel visibility -// Mix Mode State -let mixMode = false; // Single Pattern vs Mix Patterns mode +// Mode State - 'single' | 'mix' | 'dataset' +let currentMode = 'single'; let mixOperation = 'sum'; // 'sum' or 'append' let mixComponents = []; // [{name, data, changePoints, params, count}] let mixedData = null; // Combined data array let mixedChangePoints = []; // Merged ground truth change points let tileBadges = {}; // Track count badges on tiles +// Dataset Mode State +let bundledDatasets = []; // [{name, title, description}, ...] +let availableAlgorithms = {}; // {split: {title, available}, ...} +let datasetSeriesCache = {}; // name -> {series, timestamps?} + +// Helper that reads better at call sites than `currentMode === 'mix'`. +const isMixMode = () => currentMode === 'mix'; + +// Algorithm colors used when overlaying multiple algorithms on one chart. +const ALGO_COLORS = { + split: '#1f77b4', + orig: '#d62728', + deterministic: '#2ca02c', +}; + +// In-flight AbortController for /api/compare so out-of-order responses can't +// land after a newer one and desync the chart from the controls. +let datasetInflight = null; + // DOM Elements - Data Generation const generatorGrid = document.getElementById('generator-grid'); const lengthSlider = document.getElementById('length-slider'); @@ -29,15 +48,26 @@ const lengthMax = document.getElementById('length-max'); const seedInput = document.getElementById('seed-input'); const dynamicParams = document.getElementById('dynamic-params'); -// DOM Elements - Mix Mode +// DOM Elements - Mode toggle const modeSingleBtn = document.getElementById('mode-single-btn'); const modeMixBtn = document.getElementById('mode-mix-btn'); +const modeDatasetBtn = document.getElementById('mode-dataset-btn'); const mixInfo = document.getElementById('mix-info'); const mixRecipe = document.getElementById('mix-recipe'); const clearMixBtn = document.getElementById('clear-mix-btn'); +// DOM Elements - Dataset mode +const datasetSection = document.getElementById('dataset-section'); +const datasetSourceSelect = document.getElementById('dataset-source'); +const datasetDescription = document.getElementById('dataset-description'); +const customInputWrapper = document.getElementById('custom-input-wrapper'); +const customInput = document.getElementById('custom-input'); +const datasetStatusEl = document.getElementById('dataset-status'); +const datasetResultsSection = document.getElementById('dataset-results'); +const datasetResultsBody = document.getElementById('dataset-results-body'); + // DOM Elements - Otava Controls -const runOtavaCheckbox = document.getElementById('run-otava-checkbox'); +const otavaAlgoCheckboxes = document.querySelectorAll('.otava-algo-checkbox'); const windowLenInput = document.getElementById('window-len-input'); const maxPvalueInput = document.getElementById('max-pvalue-input'); const yMinInput = document.getElementById('y-min-input'); @@ -79,6 +109,25 @@ const stdDevNumInput = document.getElementById('stddev-num-input'); const DEFAULT_TOLERANCE = 0; // Exact match for True Positive const CLOSE_MATCH_TOLERANCE = 5; // Within 5 points for Close Match +/** Return the set of Otava algorithms the user has enabled. */ +function getEnabledOtavaAlgorithms() { + return Array.from(otavaAlgoCheckboxes) + .filter(el => el.checked && !el.disabled) + .map(el => el.dataset.algo); +} + +/** True if at least one Otava algorithm is enabled. */ +function isOtavaEnabled() { + return getEnabledOtavaAlgorithms().length > 0; +} + +/** Primary algorithm — the first checked one (drives the chart annotations + * + accuracy metrics in single/mix mode). Defaults to 'split'. */ +function primaryOtavaAlgorithm() { + const enabled = getEnabledOtavaAlgorithms(); + return enabled[0] || 'split'; +} + // DOM Elements - Actions const generateBtn = document.getElementById('generate-btn'); const showAllBtn = document.getElementById('show-all-btn'); @@ -128,8 +177,16 @@ const metricsTutorialPanel = document.getElementById('metrics-tutorial-panel'); // Initialize document.addEventListener('DOMContentLoaded', async () => { - await Promise.all([loadGenerators(), loadAnalysisMethods()]); + document.body.setAttribute('data-mode', 'single'); + await Promise.all([ + loadGenerators(), + loadAnalysisMethods(), + loadDatasets(), + loadAvailableAlgorithms(), + ]); await populateGeneratorGrid(); + populateDatasetUI(); + refreshDatasetSourceDescription(); setupEventListeners(); setupTutorialHandlers(); updateGeneratorInfo(); @@ -217,7 +274,7 @@ async function populateGeneratorGrid() { const generatorNames = Object.keys(generators); - if (mixMode) { + if (isMixMode()) { // Mix mode layout: Single row with clean patterns + noise + operation toggle const mixRow1Order = [ 'constant', @@ -344,15 +401,15 @@ async function populateGeneratorGrid() { } // Apply mix mode class to grid - generatorGrid.classList.toggle('mix-mode', mixMode); + generatorGrid.classList.toggle('mix-mode', isMixMode()); } /** * Create a generator tile element */ -function createGeneratorTile(name, info, preview, isMixMode) { +function createGeneratorTile(name, info, preview, forMixGrid) { const tile = document.createElement('div'); - tile.className = 'generator-tile' + (!isMixMode && name === selectedGenerator ? ' selected' : ''); + tile.className = 'generator-tile' + (!forMixGrid && name === selectedGenerator ? ' selected' : ''); tile.dataset.generator = name; // Preview container @@ -371,7 +428,7 @@ function createGeneratorTile(name, info, preview, isMixMode) { tile.appendChild(label); // Click handler - different for mix mode vs single mode - if (isMixMode) { + if (forMixGrid) { tile.addEventListener('click', () => addToMix(name)); } else { tile.addEventListener('click', () => selectGenerator(name)); @@ -379,7 +436,7 @@ function createGeneratorTile(name, info, preview, isMixMode) { // Create mini chart if (preview && preview.data) { - createTileChart(canvas, preview.data, !isMixMode && name === selectedGenerator); + createTileChart(canvas, preview.data, !forMixGrid && name === selectedGenerator); } return tile; @@ -695,13 +752,23 @@ function setupEventListeners() { generateBtn.addEventListener('click', generateData); showAllBtn.addEventListener('click', showAllPatterns); - // Mix mode controls - modeSingleBtn.addEventListener('click', () => toggleMixMode(false)); - modeMixBtn.addEventListener('click', () => toggleMixMode(true)); + // Mode toggle + modeSingleBtn.addEventListener('click', () => setMode('single')); + modeMixBtn.addEventListener('click', () => setMode('mix')); + modeDatasetBtn.addEventListener('click', () => setMode('dataset')); clearMixBtn.addEventListener('click', clearMix); + // Dataset mode controls + datasetSourceSelect.addEventListener('change', () => { + refreshDatasetSourceDescription(); + if (currentMode === 'dataset') runDatasetAnalysis(); + }); + customInput.addEventListener('change', () => { + if (currentMode === 'dataset') runDatasetAnalysis(); + }); + // Otava controls - runOtavaCheckbox.addEventListener('change', refreshDisplay); + otavaAlgoCheckboxes.forEach(cb => cb.addEventListener('change', refreshDisplay)); windowLenInput.addEventListener('change', refreshDisplay); maxPvalueInput.addEventListener('change', refreshDisplay); @@ -740,7 +807,7 @@ function setupEventListeners() { // Update generator info display function updateGeneratorInfo() { - if (mixMode && mixComponents.length > 0) { + if (isMixMode() && mixComponents.length > 0) { // Mix mode with components const totalCPs = mixedChangePoints ? mixedChangePoints.filter(cp => cp.type !== 'outlier').length : 0; generatorTitle.textContent = 'Mixed Pattern'; @@ -783,7 +850,7 @@ function updateGeneratorInfo() { // Update dynamic parameter inputs function updateDynamicParams() { - if(mixMode) updateMixParams(); + if(isMixMode()) updateMixParams(); else updateSingleParams(); } @@ -822,7 +889,7 @@ function renderParamWidgets(info, containerDiv, bindComponent){ function dynamicParamChanged(ev) { // console.log(ev); - if(mixMode && bindComponent){ + if(isMixMode() && bindComponent){ bindComponent.params[paramName] = ev.target.value; asyncRedraw(bindComponent); } @@ -1164,13 +1231,14 @@ async function generateData() { const name = selectedGenerator; const length = lengthInput.value; const seed = seedInput.value; - const runOtava = runOtavaCheckbox.checked; + const runOtava = isOtavaEnabled(); // Build query params const params = new URLSearchParams({ length, seed, run_otava: runOtava, + otava_algorithm: primaryOtavaAlgorithm(), window_len: windowLenInput.value, max_pvalue: maxPvalueInput.value, tolerance: DEFAULT_TOLERANCE, @@ -1409,7 +1477,7 @@ function updateChart(data) { // Track which is the last chart for showing X-axis const enabledMethods = []; - if (runOtavaCheckbox.checked) enabledMethods.push('otava'); + if (isOtavaEnabled()) enabledMethods.push('otava'); if (runMa) enabledMethods.push('ma'); if (runBoundary) enabledMethods.push('boundary'); if (runThreshold) enabledMethods.push('threshold'); @@ -1417,7 +1485,7 @@ function updateChart(data) { if (runStdDev) enabledMethods.push('stdDev'); // Create Otava chart if enabled - if (runOtavaCheckbox.checked) { + 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 ctx = canvas.getContext('2d'); @@ -1812,7 +1880,7 @@ function updateChart(data) { // Store all results for accuracy metrics display data._methodResults = { - otava: runOtavaCheckbox.checked ? { + otava: isOtavaEnabled() ? { name: 'Otava', classification: otavaClassification, detectedIndices: detectedIndices @@ -2220,7 +2288,9 @@ async function showAllPatterns() { * Refresh the current display - calls the appropriate update function based on mode */ function refreshDisplay() { - if (mixMode && mixComponents.length > 0) { + if (currentMode === 'dataset') { + runDatasetAnalysis(); + } else if (currentMode === 'mix' && mixComponents.length > 0) { computeAndDisplayMixedData(); } else { generateData(); @@ -2228,36 +2298,372 @@ function refreshDisplay() { } /** - * Toggle between Single Pattern and Mix Patterns mode + * Switch between the three top-level modes: 'single' | 'mix' | 'dataset'. */ -function toggleMixMode(enable) { - mixMode = enable; +function setMode(mode) { + if (!['single', 'mix', 'dataset'].includes(mode)) return; + currentMode = mode; + + // Body data-attr drives CSS that hides ground-truth sections in dataset mode. + document.body.setAttribute('data-mode', mode); + + // Update button active state + modeSingleBtn.classList.toggle('active', mode === 'single'); + modeMixBtn.classList.toggle('active', mode === 'mix'); + modeDatasetBtn.classList.toggle('active', mode === 'dataset'); + + // Show/hide the right top section + const generatorSection = document.querySelector('.generator-grid-section'); + if (generatorSection) generatorSection.classList.toggle('hidden', mode === 'dataset'); + datasetSection.classList.toggle('hidden', mode !== 'dataset'); + + // Update the section heading next to the mode toggle. + const title = document.getElementById('mode-section-title'); + if (title) { + title.textContent = + mode === 'dataset' ? 'Dataset' : + mode === 'mix' ? 'Mix Patterns' : + 'Select Pattern'; + } - // Update button states - modeSingleBtn.classList.toggle('active', !enable); - modeMixBtn.classList.toggle('active', enable); + // Mix-info banner visible only in mix mode + mixInfo.classList.toggle('hidden', mode !== 'mix'); + generatorGrid.classList.toggle('mix-mode', mode === 'mix'); - // Toggle mix info visibility - mixInfo.classList.toggle('hidden', !enable); + if (mode === 'mix') { + updateMixDisplay(); + populateGeneratorGrid(); + } else if (mode === 'single') { + clearMix(); + populateGeneratorGrid(); + if (selectedGenerator) generateData(); + } else { + // Dataset mode: hide multi-chart and clear mix state silently. + multiChartContainer.classList.add('hidden'); + runDatasetAnalysis(); + } +} - // Toggle grid class - generatorGrid.classList.toggle('mix-mode', enable); +/* ==================================================================== + Dataset Mode + ==================================================================== */ - // Clear mix state when switching modes- or maybe not... - if (enable) { - //clearMix(); - updateMixDisplay(); +async function loadDatasets() { + try { + const r = await fetch('/api/datasets'); + const body = await r.json(); + bundledDatasets = body.datasets || []; + } catch (e) { + console.error('Failed to load datasets:', e); + bundledDatasets = []; + } +} + +async function loadAvailableAlgorithms() { + try { + const r = await fetch('/api/algorithms'); + const body = await r.json(); + availableAlgorithms = body.algorithms || {}; + } catch (e) { + console.error('Failed to load algorithms:', e); + availableAlgorithms = {}; + } +} + +function populateDatasetUI() { + // Dataset source dropdown. + datasetSourceSelect.innerHTML = ''; + bundledDatasets.forEach(ds => { + const opt = document.createElement('option'); + opt.value = ds.name; + opt.textContent = ds.title; + datasetSourceSelect.appendChild(opt); + }); + const customOpt = document.createElement('option'); + customOpt.value = '__custom__'; + customOpt.textContent = 'Custom (paste below)'; + datasetSourceSelect.appendChild(customOpt); + + // Disable Otava-panel checkboxes for algorithms the installed otava doesn't expose. + for (const [name, info] of Object.entries(availableAlgorithms)) { + const cb = document.getElementById(`otava-algo-${name}`); + if (!cb) continue; + if (!info.available) { + cb.checked = false; + cb.disabled = true; + // Append a sibling indicator instead of overwriting the existing + // .algo-hint, which may contain an inline link (e.g. PR #154). + if (!cb.parentElement.querySelector('.algo-unavail')) { + const span = document.createElement('span'); + span.className = 'algo-unavail'; + span.textContent = '(not in installed otava)'; + cb.parentElement.appendChild(span); + } + } + } +} + +async function loadCurrentDatasetSeries() { + const choice = datasetSourceSelect.value; + if (choice === '__custom__') { + const { series, dropped } = parseCustomSeries(customInput.value); + return { name: 'custom', title: 'Custom', series, dropped }; + } + if (!datasetSeriesCache[choice]) { + const r = await fetch(`/api/datasets/${encodeURIComponent(choice)}`); + if (!r.ok) throw new Error(`Failed to load ${choice}: ${r.status}`); + datasetSeriesCache[choice] = await r.json(); + } + return datasetSeriesCache[choice]; +} + +function parseCustomSeries(text) { + text = (text || '').trim(); + if (!text) return { series: [], dropped: 0 }; + let raw; + if (text.startsWith('[')) { + try { raw = JSON.parse(text).map(Number); } + catch (e) { raw = text.split(/[^0-9eE.\-+]+/).filter(Boolean).map(Number); } } else { - // Clear badges and restore normal tile behavior - clearMix(); - // Re-render the selected generator in single mode - if (selectedGenerator) { - generateData(); + raw = text.split(/[^0-9eE.\-+]+/).filter(Boolean).map(Number); + } + const series = raw.filter(v => Number.isFinite(v)); + return { series, dropped: raw.length - series.length }; +} + +function refreshDatasetSourceDescription() { + const choice = datasetSourceSelect.value; + if (choice === '__custom__') { + customInputWrapper.classList.remove('hidden'); + datasetDescription.textContent = 'Paste your own numeric series.'; + return; + } + customInputWrapper.classList.add('hidden'); + const ds = bundledDatasets.find(d => d.name === choice); + datasetDescription.textContent = ds ? ds.description : ''; +} + +function setDatasetStatus(msg, isError = false) { + if (!msg) { + datasetStatusEl.hidden = true; + datasetStatusEl.textContent = ''; + return; + } + datasetStatusEl.hidden = false; + datasetStatusEl.textContent = msg; + datasetStatusEl.classList.toggle('dataset-status--error', !!isError); +} + +function emptyResultsRow(message) { + datasetResultsBody.replaceChildren(); + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = 3; + td.style.color = '#999'; + td.textContent = message; + tr.appendChild(td); + datasetResultsBody.appendChild(tr); + datasetResultsSection.classList.remove('hidden'); +} + +async function runDatasetAnalysis() { + // Claim the in-flight slot up front so every early-return path leaves + // datasetInflight in a clean state and the next call's abort() is meaningful. + if (datasetInflight) datasetInflight.abort(); + const ctrl = new AbortController(); + datasetInflight = ctrl; + const stillCurrent = () => datasetInflight === ctrl; + + let series; + let droppedTokens = 0; + try { + const ds = await loadCurrentDatasetSeries(); + series = ds.series; + droppedTokens = ds.dropped || 0; + } catch (e) { + if (stillCurrent()) { + datasetInflight = null; + setDatasetStatus(`Failed to load series: ${e.message || e}`, true); } + return; } + if (!stillCurrent()) return; + + const tooFewPoints = !series || series.length < 5; + const noAlgos = !tooFewPoints && getEnabledOtavaAlgorithms().length === 0; - // Rebuild grid for mix mode layout - populateGeneratorGrid(); + // Compose the status: token-warning + (optional) terminal hint, so an + // input problem isn't masked by the "need 5 points" message and vice versa. + const parts = []; + if (droppedTokens > 0) parts.push(`Ignored ${droppedTokens} non-numeric token(s).`); + if (tooFewPoints) parts.push('Need at least 5 numeric points.'); + setDatasetStatus(parts.join(' '), parts.length > 0); + + if (tooFewPoints) { + emptyResultsRow('Need at least 5 numeric points.'); + datasetInflight = null; + return; + } + if (noAlgos) { + renderDatasetChart(series, {}); + emptyResultsRow('Pick at least one algorithm in the Otava Analysis panel.'); + datasetInflight = null; + return; + } + + const algos = getEnabledOtavaAlgorithms(); + const params = new URLSearchParams({ + window_len: windowLenInput.value, + max_pvalue: maxPvalueInput.value, + min_magnitude: '0', + }); + + try { + document.body.classList.add('loading'); + const r = await fetch(`/api/compare?${params}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: series, algorithms: algos }), + signal: ctrl.signal, + }); + if (!r.ok) { + const err = await r.json().catch(() => ({})); + throw new Error(err.error || `HTTP ${r.status}`); + } + const body = await r.json(); + if (!stillCurrent()) return; // superseded; let the newer call render + renderDatasetChart(series, body.results); + renderDatasetResultsTable(body.results); + updateDatasetStats(series, body.results); + datasetResultsSection.classList.remove('hidden'); + } catch (e) { + if (e.name === 'AbortError') return; // expected when superseded + if (stillCurrent()) setDatasetStatus(`Compare failed: ${e.message || e}`, true); + } finally { + if (stillCurrent()) { + datasetInflight = null; + document.body.classList.remove('loading'); + } + } +} + +function renderDatasetChart(series, resultsByAlgo) { + stackedCharts.forEach(c => c.destroy()); + stackedCharts = []; + stackedChartsContainer.innerHTML = ''; + + const container = document.createElement('div'); + container.className = 'stacked-chart'; + const canvas = document.createElement('canvas'); + canvas.id = 'canvas-dataset'; + container.appendChild(canvas); + stackedChartsContainer.appendChild(container); + + const labels = series.map((_, i) => i); + const annotations = {}; + for (const [algo, info] of Object.entries(resultsByAlgo || {})) { + const color = ALGO_COLORS[algo] || '#888'; + for (const idx of (info.indices || [])) { + annotations[`${algo}-${idx}`] = { + type: 'line', + xMin: idx, xMax: idx, + borderColor: color, + borderWidth: 2, + borderDash: [4, 4], + }; + } + } + + const chart = new Chart(canvas.getContext('2d'), { + type: 'line', + data: { + labels, + datasets: [{ + label: 'Series', + data: series, + borderColor: '#444', + backgroundColor: 'rgba(0,0,0,0)', + pointRadius: 1.5, + borderWidth: 1, + tension: 0, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { title: { display: true, text: 'Index' } }, + y: { title: { display: true, text: 'Value' } }, + }, + plugins: { + legend: { position: 'top' }, + annotation: { annotations }, + }, + }, + }); + stackedCharts.push(chart); +} + +function renderDatasetResultsTable(resultsByAlgo) { + // Build rows as DOM nodes so server-supplied strings (algorithm names, + // error messages) can't break out and inject HTML. + datasetResultsBody.replaceChildren(); + const entries = Object.entries(resultsByAlgo); + if (entries.length === 0) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = 3; + td.style.color = '#999'; + td.textContent = 'No algorithms selected.'; + tr.appendChild(td); + datasetResultsBody.appendChild(tr); + return; + } + for (const [algo, info] of entries) { + const color = ALGO_COLORS[algo] || '#888'; + const indices = info.indices || []; + const tr = document.createElement('tr'); + + const tdName = document.createElement('td'); + const swatch = document.createElement('span'); + swatch.className = 'swatch'; + swatch.style.background = color; + tdName.appendChild(swatch); + tdName.appendChild(document.createTextNode(algo)); + if (info.error) { + const span = document.createElement('span'); + span.style.color = '#900'; + span.textContent = ` (${info.error})`; + tdName.appendChild(span); + } + tr.appendChild(tdName); + + const tdCount = document.createElement('td'); + tdCount.textContent = String(indices.length); + tr.appendChild(tdCount); + + const tdIdx = document.createElement('td'); + const code = document.createElement('code'); + code.textContent = indices.length ? indices.join(', ') : '—'; + tdIdx.appendChild(code); + tr.appendChild(tdIdx); + + datasetResultsBody.appendChild(tr); + } +} + +function updateDatasetStats(series, resultsByAlgo) { + const n = series.length; + const mean = series.reduce((a, b) => a + b, 0) / n; + const variance = series.reduce((a, v) => a + (v - mean) ** 2, 0) / n; + const std = Math.sqrt(variance); + statLength.textContent = n.toString(); + statMean.textContent = mean.toFixed(2); + statStd.textContent = std.toFixed(2); + statCpTruth.textContent = '—'; + const totalDetected = Object.values(resultsByAlgo || {}) + .reduce((a, info) => a + (info.indices?.length || 0), 0); + statCpDetected.textContent = totalDetected.toString(); } /** @@ -2468,7 +2874,7 @@ function clearMix() { updateMixDisplay(); updateTileBadges(); - if (mixMode) { + if (isMixMode()) { // Clear charts stackedChartsContainer.innerHTML = `
@@ -2658,7 +3064,7 @@ async function computeAndDisplayMixedData() { }; // Run Otava analysis on mixed data if enabled - if (runOtavaCheckbox.checked) { + if (isOtavaEnabled()) { try { const params = new URLSearchParams({ window_len: windowLenInput.value, @@ -2773,7 +3179,7 @@ function updateTileBadges() { tile.classList.remove('in-mix'); }); - if (!mixMode) return; + if (!isMixMode()) return; // Add badges for components in mix const sumClicks = {}; diff --git a/src/otava_test_data/web/templates/index.html b/src/otava_test_data/web/templates/index.html index 7649ad0..e0c9057 100644 --- a/src/otava_test_data/web/templates/index.html +++ b/src/otava_test_data/web/templates/index.html @@ -6,7 +6,7 @@ Otava Test Data Visualizer - +
@@ -53,15 +53,20 @@

Settings

- -
+ +
-

Select Pattern

+

Select Pattern

+
+
+ + +
+ + + -
+
@@ -88,7 +113,7 @@

Select Pattern

Analysis Controls

-
+
@@ -118,11 +143,29 @@

Analysis Controls

- - + Otava Analysis
+
+ + + +
+
@@ -152,7 +195,7 @@

Analysis Controls

-
+
@@ -187,7 +230,7 @@

Analysis Controls

-
+
@@ -219,7 +262,7 @@

Analysis Controls

-
+
@@ -237,7 +280,7 @@

Analysis Controls

-
+
@@ -259,7 +302,7 @@

Analysis Controls

-
+
@@ -279,7 +322,7 @@

Analysis Controls

-
+

Constant

@@ -309,7 +352,7 @@

Detection Notes

-
+
Ground Truth @@ -329,7 +372,7 @@

Detection Notes

-
+

Detection Accuracy

@@ -375,7 +418,7 @@

Otava Detected

-
+

Change Points Comparison

@@ -412,6 +455,17 @@

Otava Detected

+ +
+ + + + +
AlgorithmCountIndices
+
+