Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/otava_test_data/tests/test_compare_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,53 @@ def test_analyze_accepts_otava_algorithm():
assert "otava" in body
assert "error" not in body["otava"]
assert body["otava"]["parameters"]["algorithm"] == "orig"


def test_compare_applies_per_algorithm_params():
"""`algorithm_params` should override the query-string defaults per algorithm,
and missing per-algorithm keys should fall back to those defaults."""
series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0]
r = client.post(
"/api/compare?max_pvalue=0.5&window_len=40",
json={
"data": series,
"algorithms": ["split", "orig"],
"algorithm_params": {
"split": {"window_len": 20, "max_pvalue": 0.001},
"orig": {"max_pvalue": 0.01},
},
},
)
assert r.status_code == 200
body = r.json()
# The endpoint echoes back the resolved per-algorithm params so the UI can
# display "what was actually run."
assert body["algorithm_params"]["split"] == {
"window_len": 20, "max_pvalue": 0.001, "min_magnitude": 0.0,
}
# `orig` only overrode max_pvalue; window_len falls back to the query default.
assert body["algorithm_params"]["orig"] == {
"window_len": 40, "max_pvalue": 0.01, "min_magnitude": 0.0,
}


def test_compare_without_per_algorithm_params_uses_query_defaults():
"""`algorithm_params` is optional — falling back to query defaults must still work."""
series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0]
r = client.post(
"/api/compare?max_pvalue=0.05",
json={"data": series, "algorithms": ["split"]},
)
assert r.status_code == 200
assert r.json()["algorithm_params"]["split"]["max_pvalue"] == 0.05


def test_detect_accepts_otava_algorithm():
"""`/api/detect` (used by mix mode) should respect the chosen algorithm."""
series = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0]
r = client.post(
"/api/detect?otava_algorithm=orig&max_pvalue=0.05",
json={"data": series},
)
assert r.status_code == 200
assert r.json()["parameters"]["algorithm"] == "orig"
48 changes: 41 additions & 7 deletions src/otava_test_data/web/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1568,7 +1568,7 @@ async def analyze_with_otava(
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=_OTAVA_DEFAULTS.window_len, ge=5, le=100, description="Otava window length"),
window_len: int = Query(default=_OTAVA_DEFAULTS.window_len, ge=5, le=100000, description="Otava window length"),
max_pvalue: float = Query(default=_OTAVA_DEFAULTS.max_pvalue, ge=0.0, le=1.0, description="Otava max p-value"),
min_magnitude: float = Query(default=_OTAVA_DEFAULTS.min_magnitude, ge=0, description="Minimum change magnitude"),
tolerance: int = Query(default=5, ge=0, le=50, description="Accuracy tolerance"),
Expand Down Expand Up @@ -1675,6 +1675,7 @@ class DetectRequest(BaseModel):
@app.post("/api/detect")
async def detect_change_points(
request: DetectRequest,
otava_algorithm: AlgorithmName = Query(default="split", description="Otava algorithm to run"), # noqa: B008
window_len: int = Query(default=_OTAVA_DEFAULTS.window_len, ge=5, le=100000, description="Otava window length"),
max_pvalue: float = Query(default=_OTAVA_DEFAULTS.max_pvalue, ge=0.0, le=1.0, description="Otava max p-value"),
min_magnitude: float = Query(default=_OTAVA_DEFAULTS.min_magnitude, ge=0, description="Minimum change magnitude"),
Expand All @@ -1693,6 +1694,7 @@ async def detect_change_points(
window_len=window_len,
max_pvalue=max_pvalue,
min_magnitude=min_magnitude,
algorithm=otava_algorithm,
)
return result
except Exception as e:
Expand Down Expand Up @@ -1749,10 +1751,20 @@ def _run_algorithm(name: str, data, window_len: int, max_pvalue: float,
return out


class AlgorithmParams(BaseModel):
"""Optional per-algorithm parameter overrides for /api/compare."""
window_len: int | None = None
max_pvalue: float | None = None
min_magnitude: float | None = None


class CompareRequest(BaseModel):
"""Request body for /api/compare."""
data: list[float]
algorithms: list[AlgorithmName] | None = None # default: all available
# Per-algorithm overrides; missing fields fall back to the request's query-string
# defaults (window_len / max_pvalue / min_magnitude).
algorithm_params: dict[AlgorithmName, AlgorithmParams] | None = None


@app.get("/api/datasets")
Expand Down Expand Up @@ -1783,25 +1795,47 @@ async def compare_algorithms(
max_pvalue: float = Query(default=_OTAVA_DEFAULTS.max_pvalue, ge=0.0, le=1.0),
min_magnitude: float = Query(default=_OTAVA_DEFAULTS.min_magnitude, ge=0),
):
"""Run multiple change-point algorithms on the same series and return all results."""
"""Run multiple change-point algorithms on the same series and return all results.

Each algorithm can have its own parameter overrides via `algorithm_params`;
missing keys fall back to the query-string defaults.
"""
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
}
overrides = request.algorithm_params or {}

def params_for(name: str) -> dict[str, float]:
override = overrides.get(name)
if override is None:
return {"window_len": window_len, "max_pvalue": max_pvalue,
"min_magnitude": min_magnitude}
return {
"window_len": override.window_len if override.window_len is not None else window_len,
"max_pvalue": override.max_pvalue if override.max_pvalue is not None else max_pvalue,
"min_magnitude": (
override.min_magnitude if override.min_magnitude is not None else min_magnitude
),
}

results = {}
per_algo_params = {}
for name in algorithms:
p = params_for(name)
per_algo_params[name] = p
results[name] = _run_algorithm(name, request.data, **p)

return {
"results": results,
"parameters": {
"window_len": window_len,
"max_pvalue": max_pvalue,
"min_magnitude": min_magnitude,
},
"algorithm_params": per_algo_params,
}


Expand Down
52 changes: 50 additions & 2 deletions src/otava_test_data/web/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1097,7 +1097,7 @@ footer a:hover {
}

/* Wider input for p-value to accommodate small values like 0.00001 */
#max-pvalue-input {
.max-pvalue-input {
width: 90px;
}

Expand Down Expand Up @@ -1990,11 +1990,21 @@ body[data-mode="dataset"] .hide-in-dataset {
.otava-algos {
display: flex;
flex-direction: column;
gap: 4px;
gap: 10px;
margin: 4px 0 12px;
font-size: 13px;
}

.otava-algo {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 8px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 4px;
background: rgba(248, 250, 252, 0.5);
}

.otava-algo-row {
display: flex;
align-items: center;
Expand All @@ -2015,3 +2025,41 @@ body[data-mode="dataset"] .hide-in-dataset {
color: #64748b;
font-size: 11px;
}

/* Per-algorithm parameter rows nested inside .otava-algo */
.otava-algo .algo-params {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-left: 24px;
}

.otava-algo .algo-params .control-group {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}

.otava-algo .algo-params .control-group label {
font-size: 0.75rem;
white-space: nowrap;
display: inline-block;
width: 100px;
text-align: left;
}

.otava-algo .algo-params .control-group input {
width: 70px;
padding: 0.25rem 0.4rem;
font-size: 0.85rem;
}

.otava-algo .algo-params .max-pvalue-input {
width: 90px;
}

/* Dim parameter inputs when the algorithm checkbox is unchecked. */
.otava-algo:has(.otava-algo-checkbox:not(:checked)) .algo-params {
opacity: 0.45;
}
70 changes: 49 additions & 21 deletions src/otava_test_data/web/static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,7 @@ const datasetResultsBody = document.getElementById('dataset-results-body');

// DOM Elements - Otava Controls
const otavaAlgoCheckboxes = document.querySelectorAll('.otava-algo-checkbox');
const windowLenInput = document.getElementById('window-len-input');
const maxPvalueInput = document.getElementById('max-pvalue-input');
const otavaAlgoParamInputs = document.querySelectorAll('.otava-algos [data-algo-param]');
const yMinInput = document.getElementById('y-min-input');
const yMaxInput = document.getElementById('y-max-input');
const yMinSlider = document.getElementById('y-min-slider');
Expand Down Expand Up @@ -128,6 +127,30 @@ function primaryOtavaAlgorithm() {
return enabled[0] || 'split';
}

/** Read the per-algorithm parameter inputs and return a flat dict suitable
* for query-string serialization (e.g. {window_len, max_pvalue}). Window
* length only exists for 'split'; missing inputs are simply absent.
*
* Reads the input values regardless of whether the algorithm's checkbox is
* checked — callers (e.g. generateData with the primary algo) may need the
* params even when the algo isn't "enabled" in the multi-select sense. */
function getOtavaParamsForAlgo(algoName) {
const params = {};
document
.querySelectorAll(`.otava-algo[data-algo="${algoName}"] [data-algo-param]`)
.forEach(input => { params[input.dataset.algoParam] = input.value; });
return params;
}

/** Per-algorithm params for every enabled algorithm — used by /api/compare. */
function getOtavaParamsByAlgo() {
const out = {};
for (const name of getEnabledOtavaAlgorithms()) {
out[name] = getOtavaParamsForAlgo(name);
}
return out;
}

// DOM Elements - Actions
const generateBtn = document.getElementById('generate-btn');
const showAllBtn = document.getElementById('show-all-btn');
Expand Down Expand Up @@ -769,8 +792,7 @@ function setupEventListeners() {

// Otava controls
otavaAlgoCheckboxes.forEach(cb => cb.addEventListener('change', refreshDisplay));
windowLenInput.addEventListener('change', refreshDisplay);
maxPvalueInput.addEventListener('change', refreshDisplay);
otavaAlgoParamInputs.forEach(input => input.addEventListener('change', refreshDisplay));

// Y-Axis Min slider with bounds
setupSliderWithBounds(yMinSlider, yMinInput, yMinBoundMin, yMinBoundMax, refreshDisplay);
Expand Down Expand Up @@ -1233,15 +1255,18 @@ async function generateData() {
const seed = seedInput.value;
const runOtava = isOtavaEnabled();

// Build query params
// 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: primaryOtavaAlgorithm(),
window_len: windowLenInput.value,
max_pvalue: maxPvalueInput.value,
otava_algorithm: algo,
tolerance: DEFAULT_TOLERANCE,
...algoParams,
});

// Add dynamic params
Expand Down Expand Up @@ -2055,9 +2080,10 @@ function updateComparisonTables(data) {
async function showAllPatterns() {
const length = lengthInput.value;
const seed = seedInput.value;
const windowLen = windowLenInput.value;
const maxPvalue = maxPvalueInput.value;
const tolerance = DEFAULT_TOLERANCE;
// /api/analyze runs a single algorithm; "Show All" pins it to the primary.
const algo = primaryOtavaAlgorithm();
const algoParams = getOtavaParamsForAlgo(algo);

try {
document.body.classList.add('loading');
Expand All @@ -2071,9 +2097,9 @@ async function showAllPatterns() {
const params = new URLSearchParams({
length,
seed,
window_len: windowLen,
max_pvalue: maxPvalue,
tolerance,
otava_algorithm: algo,
...algoParams,
});

const response = await fetch(`/api/analyze/${name}?${params}`);
Expand Down Expand Up @@ -2512,18 +2538,18 @@ async function runDatasetAnalysis() {
}

const algos = getEnabledOtavaAlgorithms();
const params = new URLSearchParams({
window_len: windowLenInput.value,
max_pvalue: maxPvalueInput.value,
min_magnitude: '0',
});
const algoParams = getOtavaParamsByAlgo();

try {
document.body.classList.add('loading');
const r = await fetch(`/api/compare?${params}`, {
const r = await fetch(`/api/compare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: series, algorithms: algos }),
body: JSON.stringify({
data: series,
algorithms: algos,
algorithm_params: algoParams,
}),
signal: ctrl.signal,
});
if (!r.ok) {
Expand Down Expand Up @@ -3076,9 +3102,11 @@ async function computeAndDisplayMixedData() {
// Run Otava analysis on mixed data if enabled
if (isOtavaEnabled()) {
try {
// The mix chart renders a single Otava result; pick the primary algo.
const algo = primaryOtavaAlgorithm();
const params = new URLSearchParams({
window_len: windowLenInput.value,
max_pvalue: maxPvalueInput.value,
otava_algorithm: algo,
...getOtavaParamsForAlgo(algo),
});
const response = await fetch(`/api/detect?${params}`, {
method: 'POST',
Expand Down
Loading