From 497d0838346ea0f0382e549b214ba6f99d9832be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Wed, 5 Aug 2026 15:08:02 +0200 Subject: [PATCH 01/15] Fix stale results on UI with empty cusums --- oonipipeline/src/oonipipeline/events_panel/panel.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/oonipipeline/src/oonipipeline/events_panel/panel.py b/oonipipeline/src/oonipipeline/events_panel/panel.py index cfe28988..5b30e73d 100644 --- a/oonipipeline/src/oonipipeline/events_panel/panel.py +++ b/oonipipeline/src/oonipipeline/events_panel/panel.py @@ -52,6 +52,10 @@ def detector_panel(): # Only recompute on submit; store in session_state so results survive # reruns triggered by the selectboxes below. if submitted: + # Drop any cusum-related state left over from a previous submission + for key in ("changepoints", "cusum_steps", "block_type_select", "asn_select"): + st.session_state.pop(key, None) + changepoints, _, cusum_steps = run_detector_cached( clickhouse_url, to_datetime(start_time), @@ -86,11 +90,15 @@ def detector_panel(): c2.write(f"Cusum steps: **{len(cusum_steps)}**") c1, c2 = st.columns(2) - block_type = c1.selectbox("Block type", [c[0] for c in ANALYSIS_COLS]) + block_type = c1.selectbox( + "Block type", [c[0] for c in ANALYSIS_COLS], key="block_type_select" + ) asn_list = list(asns.keys()) asn_list.sort(key=lambda k: asns[k], reverse=True) - selected_asn = c2.selectbox("ASN", asn_list, format_func=lambda k: f"{k} ({asns[k]})") + selected_asn = c2.selectbox( + "ASN", asn_list, format_func=lambda k: f"{k} ({asns[k]})", key="asn_select" + ) chart_steps = [s for s in cusum_steps if s["probe_asn"] == selected_asn] From bbd736f2c7053c784b731b366f55b8b74e37e5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Thu, 6 Aug 2026 14:01:45 +0200 Subject: [PATCH 02/15] Add debug panel with png version of chart --- oonipipeline/pyproject.toml | 1 + oonipipeline/src/oonipipeline/events_panel/panel.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/oonipipeline/pyproject.toml b/oonipipeline/pyproject.toml index 4feef080..2e66f9c1 100644 --- a/oonipipeline/pyproject.toml +++ b/oonipipeline/pyproject.toml @@ -42,6 +42,7 @@ analysis = [ "bokeh ~= 3.5.2", "streamlit", "pyarrow < 19", # fixes segfault with streamlit + "vl-convert-python>=1.9.0.post1", ] [tool.hatch.build.targets.sdist] diff --git a/oonipipeline/src/oonipipeline/events_panel/panel.py b/oonipipeline/src/oonipipeline/events_panel/panel.py index 5b30e73d..07cec37b 100644 --- a/oonipipeline/src/oonipipeline/events_panel/panel.py +++ b/oonipipeline/src/oonipipeline/events_panel/panel.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone, timedelta, date import logging import pandas as pd +import vl_convert as vlc log = logging.getLogger(__name__) @@ -106,7 +107,8 @@ def detector_panel(): st.warning(f"No cusum steps found for ASN {selected_asn}") return - st.altair_chart(make_cusums_chart(chart_steps, block_type)) + chart = make_cusums_chart(chart_steps, block_type) + st.altair_chart(chart) if asns: df = pd.DataFrame({"ASN": list(asns.keys()), "total": list(asns.values())}) @@ -114,5 +116,14 @@ def detector_panel(): df["ASN"] = df["ASN"].astype(str) st.dataframe(df, hide_index=True) + with st.expander("šŸ”§ Debug"): + if st.checkbox("Render chart as PNG", key="debug_render_png"): + spec = chart.to_dict() + png_bytes = vlc.vegalite_to_png(spec, scale=2) + st.image(png_bytes, caption="Chart rendered to PNG via vl-convert") + + if st.checkbox("Show Vega-Lite spec", key="debug_show_spec"): + st.json(chart.to_dict()) + detector_panel() From 03a397bfacacc12a49ca9038b29ece2fd04d54ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Thu, 6 Aug 2026 14:22:39 +0200 Subject: [PATCH 03/15] Add hour to chart --- .../src/oonipipeline/analysis/detector.py | 31 ++++++++++++++----- .../src/oonipipeline/events_panel/panel.py | 13 +++----- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 52746761..f90d5589 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -585,6 +585,8 @@ def make_cusums_chart(steps: List[CusumStep], block_type: str): import pandas as pd STATE_COLORS = {"ok": "#d4edda", "blk": "#f8d7da", "unk": "#fff3cd"} + x_axis = alt.Axis(format="%b %d, %H:%M") + ts_tooltip_fmt = "%b %d, %H:%M" df_steps = pd.DataFrame([s for s in steps if s["block_type"] == block_type]) df_last = df_steps.loc[[df_steps["ts"].idxmax()]] @@ -608,7 +610,7 @@ def make_cusums_chart(steps: List[CusumStep], block_type: str): alt.Chart(df_bands) .mark_rect(opacity=0.25) .encode( - x=alt.X("state_start:T"), + x=alt.X("state_start:T", axis=x_axis), x2=alt.X2("state_end:T"), color=alt.Color( "current_state:N", @@ -622,11 +624,15 @@ def make_cusums_chart(steps: List[CusumStep], block_type: str): ), legend=alt.Legend(title="State"), ), - tooltip=["current_state:N", "state_start:T", "state_end:T"], + tooltip=[ + "current_state:N", + alt.Tooltip("state_start:T", format=ts_tooltip_fmt), + alt.Tooltip("state_end:T", format=ts_tooltip_fmt), + ], ) ) - base = alt.Chart(df_steps).encode(x="ts:T") + base = alt.Chart(df_steps).encode(x=alt.X("ts:T", axis=x_axis)) def make_label(df, field, label, color): return ( @@ -641,15 +647,20 @@ def make_label(df, field, label, color): obs_line = base.mark_line(color="steelblue", opacity=0.5).encode( y=alt.Y("obs_value:Q", title="value"), - tooltip=["ts:T", "obs_value:Q", "weight:Q", "current_state:N"], + tooltip=[ + alt.Tooltip("ts:T", format=ts_tooltip_fmt), + "obs_value:Q", + "weight:Q", + "current_state:N", + ], ) s_pos_line = base.mark_line(color="red").encode( y=alt.Y("s_pos:Q"), - tooltip=["ts:T", "s_pos:Q"], + tooltip=[alt.Tooltip("ts:T", format=ts_tooltip_fmt), "s_pos:Q"], ) s_neg_line = base.mark_line(color="orange").encode( y=alt.Y("s_neg:Q"), - tooltip=["ts:T", "s_neg:Q"], + tooltip=[alt.Tooltip("ts:T", format=ts_tooltip_fmt), "s_neg:Q"], ) threshold = ( alt.Chart(df_steps).mark_rule(color="green", strokeDash=[4, 4]).encode(y="h:Q") @@ -660,7 +671,13 @@ def make_label(df, field, label, color): .encode( x="ts:T", y=alt.Y("obs_value:Q"), - tooltip=["ts:T", "obs_value:Q", "s_pos:Q", "s_neg:Q", "current_state:N"], + tooltip=[ + alt.Tooltip("ts:T", format=ts_tooltip_fmt), + "obs_value:Q", + "s_pos:Q", + "s_neg:Q", + "current_state:N", + ], ) ) diff --git a/oonipipeline/src/oonipipeline/events_panel/panel.py b/oonipipeline/src/oonipipeline/events_panel/panel.py index 07cec37b..712b838d 100644 --- a/oonipipeline/src/oonipipeline/events_panel/panel.py +++ b/oonipipeline/src/oonipipeline/events_panel/panel.py @@ -1,16 +1,13 @@ from collections import defaultdict import streamlit as st from oonipipeline.analysis.detector import make_cusums_chart, run_detector_for, ANALYSIS_COLS -from datetime import datetime, timezone, timedelta, date +from datetime import datetime, timezone, timedelta import logging import pandas as pd import vl_convert as vlc log = logging.getLogger(__name__) -def to_datetime(d: date): - return datetime(year=d.year, month=d.month, day=d.day, hour=0, tzinfo=timezone.utc) - @st.cache_data(ttl=300) def run_detector_cached(*args, **kwargs): @@ -38,12 +35,12 @@ def detector_panel(): c1, c2 = st.columns(2) # column 1 - start_time = c1.date_input("**Start time**", now - timedelta(days=30)) + start_time = c1.datetime_input("**Start date**", now - timedelta(days=30)) probe_cc = c1.text_input("**Country code (two chars)**", "VE") edd = c1.number_input("**Estimated Detection Delay (EDD)**", value=10) # column2 - end_time = c2.date_input("**End time**", now) + end_time = c2.datetime_input("**End date**", now) domain = c2.text_input("**domain**", "x.com") gap_halflife = c2.number_input("**Gap half life**", value=48.0) @@ -59,8 +56,8 @@ def detector_panel(): changepoints, _, cusum_steps = run_detector_cached( clickhouse_url, - to_datetime(start_time), - to_datetime(end_time), + start_time, + end_time, probe_cc, [domain], edd, From bead93d312a23173c6a88e0a7d58d76abd64bcd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Thu, 6 Aug 2026 14:52:20 +0200 Subject: [PATCH 04/15] make hover label show you the value of the closest chart --- .../src/oonipipeline/analysis/detector.py | 115 +++++++++++++----- 1 file changed, 86 insertions(+), 29 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index f90d5589..ea0de202 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -645,25 +645,69 @@ def make_label(df, field, label, color): ) ) + def make_nearest_hover(df, value_vars, colors, labels, y_title): + """ + Builds an invisible hit-target layer plus a visible highlight dot per + series, using a melted long-form copy so the "nearest" selection + matches in true 2D (ts, value) space — hovering picks whichever + series is visually closest to the cursor rather than just the + closest timestamp. + """ + df_melt = df.melt( + id_vars=["ts"], + value_vars=value_vars, + var_name="series", + value_name="value", + ) + df_melt["series_label"] = df_melt["series"].map(labels) + + nearest = alt.selection_single( + nearest=True, + on="mouseover", + fields=["ts", "series"], + empty="none", + name=f"nearest_{'_'.join(value_vars)}", + ) + melt_base = alt.Chart(df_melt).encode( + x=alt.X("ts:T", axis=x_axis), + y=alt.Y("value:Q", title=y_title), + color=alt.Color( + "series:N", + scale=alt.Scale( + domain=list(colors.keys()), range=list(colors.values()) + ), + legend=None, + ), + ) + selectors = ( + melt_base.mark_point() + .encode( + opacity=alt.value(0), + tooltip=[ + alt.Tooltip("ts:T", format=ts_tooltip_fmt), + alt.Tooltip("series_label:N", title="series"), + alt.Tooltip("value:Q"), + ], + ) + .add_selection(nearest) + ) + hover_rule = base.mark_rule(color="gray").encode( + opacity=alt.condition(nearest, alt.value(0.3), alt.value(0)) + ) + highlight_points = melt_base.mark_point(filled=True, size=80).encode( + opacity=alt.condition(nearest, alt.value(1), alt.value(0)) + ) + return hover_rule + highlight_points + selectors + obs_line = base.mark_line(color="steelblue", opacity=0.5).encode( y=alt.Y("obs_value:Q", title="value"), - tooltip=[ - alt.Tooltip("ts:T", format=ts_tooltip_fmt), - "obs_value:Q", - "weight:Q", - "current_state:N", - ], ) - s_pos_line = base.mark_line(color="red").encode( - y=alt.Y("s_pos:Q"), - tooltip=[alt.Tooltip("ts:T", format=ts_tooltip_fmt), "s_pos:Q"], - ) - s_neg_line = base.mark_line(color="orange").encode( - y=alt.Y("s_neg:Q"), - tooltip=[alt.Tooltip("ts:T", format=ts_tooltip_fmt), "s_neg:Q"], - ) - threshold = ( - alt.Chart(df_steps).mark_rule(color="green", strokeDash=[4, 4]).encode(y="h:Q") + obs_hover = make_nearest_hover( + df_steps, + ["obs_value"], + {"obs_value": "steelblue"}, + {"obs_value": "observed"}, + y_title="value", ) cp_points = ( alt.Chart(df_steps[df_steps["is_changepoint"]]) @@ -680,8 +724,21 @@ def make_label(df, field, label, color): ], ) ) - obs_label = make_label(df_last, "obs_value", "observed", "steelblue") + value_group = alt.layer(obs_line, obs_hover, cp_points, obs_label) + + s_pos_line = base.mark_line(color="red").encode(y=alt.Y("s_pos:Q")) + s_neg_line = base.mark_line(color="orange").encode(y=alt.Y("s_neg:Q")) + cusum_hover = make_nearest_hover( + df_steps, + ["s_pos", "s_neg"], + {"s_pos": "red", "s_neg": "orange"}, + {"s_pos": "S+", "s_neg": "Sāˆ’"}, + y_title="CUSUM statistic", + ) + threshold = ( + alt.Chart(df_steps).mark_rule(color="green", strokeDash=[4, 4]).encode(y="h:Q") + ) s_pos_label = make_label(df_last, "s_pos", "S+", "red") s_neg_label = make_label(df_last, "s_neg", "Sāˆ’", "orange") df_h = df_last[["ts", "h"]].copy() @@ -690,20 +747,20 @@ def make_label(df, field, label, color): .mark_text(align="left", dx=5, fontSize=11, color="green") .encode(x="ts:T", y="h:Q", text=alt.value("threshold (h)")) ) + cusum_group = alt.layer( + s_pos_line, + s_neg_line, + cusum_hover, + threshold, + s_pos_label, + s_neg_label, + threshold_label, + ) chart = ( - ( - state_bands - + obs_line - + obs_label - + s_pos_line - + s_pos_label - + s_neg_line - + s_neg_label - + threshold - + threshold_label - + cp_points - ) + alt.layer(state_bands, value_group, cusum_group) + .resolve_scale(y="independent", color="independent") + .resolve_legend(color="independent") .properties( width=900, height=400, From 661304f52ada476a7b4d7963ed9df487b4930c75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Thu, 6 Aug 2026 15:07:26 +0200 Subject: [PATCH 05/15] Add legend with lines colors --- .../src/oonipipeline/analysis/detector.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index ea0de202..a09c562c 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -645,13 +645,14 @@ def make_label(df, field, label, color): ) ) - def make_nearest_hover(df, value_vars, colors, labels, y_title): + def make_nearest_hover(df, value_vars, colors, labels, y_title, legend_title): """ Builds an invisible hit-target layer plus a visible highlight dot per series, using a melted long-form copy so the "nearest" selection matches in true 2D (ts, value) space — hovering picks whichever series is visually closest to the cursor rather than just the - closest timestamp. + closest timestamp. The same color scale drives a legend labeling + each line. """ df_melt = df.melt( id_vars=["ts"], @@ -672,11 +673,12 @@ def make_nearest_hover(df, value_vars, colors, labels, y_title): x=alt.X("ts:T", axis=x_axis), y=alt.Y("value:Q", title=y_title), color=alt.Color( - "series:N", + "series_label:N", scale=alt.Scale( - domain=list(colors.keys()), range=list(colors.values()) + domain=[labels[v] for v in value_vars], + range=[colors[v] for v in value_vars], ), - legend=None, + legend=alt.Legend(title=legend_title), ), ) selectors = ( @@ -708,6 +710,7 @@ def make_nearest_hover(df, value_vars, colors, labels, y_title): {"obs_value": "steelblue"}, {"obs_value": "observed"}, y_title="value", + legend_title="Value", ) cp_points = ( alt.Chart(df_steps[df_steps["is_changepoint"]]) @@ -731,10 +734,11 @@ def make_nearest_hover(df, value_vars, colors, labels, y_title): s_neg_line = base.mark_line(color="orange").encode(y=alt.Y("s_neg:Q")) cusum_hover = make_nearest_hover( df_steps, - ["s_pos", "s_neg"], - {"s_pos": "red", "s_neg": "orange"}, - {"s_pos": "S+", "s_neg": "Sāˆ’"}, + ["s_pos", "s_neg", "h"], + {"s_pos": "red", "s_neg": "orange", "h": "green"}, + {"s_pos": "S+", "s_neg": "Sāˆ’", "h": "threshold (h)"}, y_title="CUSUM statistic", + legend_title="CUSUM", ) threshold = ( alt.Chart(df_steps).mark_rule(color="green", strokeDash=[4, 4]).encode(y="h:Q") From 6f60c9eeededbe5dc625604799b107cbc600b323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Thu, 6 Aug 2026 15:22:27 +0200 Subject: [PATCH 06/15] Show label with both S+ and S- --- .../src/oonipipeline/analysis/detector.py | 122 +++++++++++------- 1 file changed, 74 insertions(+), 48 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index a09c562c..f3504730 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -645,50 +645,27 @@ def make_label(df, field, label, color): ) ) - def make_nearest_hover(df, value_vars, colors, labels, y_title, legend_title): + def make_nearest_hover(df, field, label, color, y_title, legend_title): """ - Builds an invisible hit-target layer plus a visible highlight dot per - series, using a melted long-form copy so the "nearest" selection - matches in true 2D (ts, value) space — hovering picks whichever - series is visually closest to the cursor rather than just the - closest timestamp. The same color scale drives a legend labeling - each line. + Builds an invisible hit-target layer plus a visible highlight dot + for a single series. Uses a plain x-only "nearest" selection (cheap: + a sorted bisect) rather than a 2D Voronoi nearest selection, which + is what made panning/zooming sluggish. """ - df_melt = df.melt( - id_vars=["ts"], - value_vars=value_vars, - var_name="series", - value_name="value", - ) - df_melt["series_label"] = df_melt["series"].map(labels) - nearest = alt.selection_single( - nearest=True, - on="mouseover", - fields=["ts", "series"], - empty="none", - name=f"nearest_{'_'.join(value_vars)}", + nearest=True, on="mouseover", fields=["ts"], empty="none", name=f"nearest_{field}" ) - melt_base = alt.Chart(df_melt).encode( + hover_base = alt.Chart(df).encode( x=alt.X("ts:T", axis=x_axis), - y=alt.Y("value:Q", title=y_title), - color=alt.Color( - "series_label:N", - scale=alt.Scale( - domain=[labels[v] for v in value_vars], - range=[colors[v] for v in value_vars], - ), - legend=alt.Legend(title=legend_title), - ), + y=alt.Y(f"{field}:Q", title=y_title), ) selectors = ( - melt_base.mark_point() + hover_base.mark_point() .encode( opacity=alt.value(0), tooltip=[ alt.Tooltip("ts:T", format=ts_tooltip_fmt), - alt.Tooltip("series_label:N", title="series"), - alt.Tooltip("value:Q"), + alt.Tooltip(f"{field}:Q", title=label), ], ) .add_selection(nearest) @@ -696,19 +673,35 @@ def make_nearest_hover(df, value_vars, colors, labels, y_title, legend_title): hover_rule = base.mark_rule(color="gray").encode( opacity=alt.condition(nearest, alt.value(0.3), alt.value(0)) ) - highlight_points = melt_base.mark_point(filled=True, size=80).encode( - opacity=alt.condition(nearest, alt.value(1), alt.value(0)) + highlight_point = hover_base.mark_point( + color=color, filled=True, size=80 + ).encode(opacity=alt.condition(nearest, alt.value(1), alt.value(0))) + return hover_rule + highlight_point + selectors + + def make_legend_swatch(labels, colors, title): + """A zero-opacity mark whose sole purpose is to render a fixed-domain + color legend, since the actual lines it labels use static (not + data-driven) mark colors and so don't produce one on their own.""" + return ( + alt.Chart(pd.DataFrame({"label": labels})) + .mark_point(filled=True, opacity=0) + .encode( + color=alt.Color( + "label:N", + scale=alt.Scale(domain=labels, range=colors), + legend=alt.Legend(title=title), + ) + ) ) - return hover_rule + highlight_points + selectors obs_line = base.mark_line(color="steelblue", opacity=0.5).encode( y=alt.Y("obs_value:Q", title="value"), ) obs_hover = make_nearest_hover( df_steps, - ["obs_value"], - {"obs_value": "steelblue"}, - {"obs_value": "observed"}, + "obs_value", + "observed", + "steelblue", y_title="value", legend_title="Value", ) @@ -728,18 +721,47 @@ def make_nearest_hover(df, value_vars, colors, labels, y_title, legend_title): ) ) obs_label = make_label(df_last, "obs_value", "observed", "steelblue") - value_group = alt.layer(obs_line, obs_hover, cp_points, obs_label) + obs_legend = make_legend_swatch(["observed"], ["steelblue"], "Value") + value_group = alt.layer(obs_line, obs_hover, cp_points, obs_label, obs_legend) - s_pos_line = base.mark_line(color="red").encode(y=alt.Y("s_pos:Q")) + s_pos_line = base.mark_line(color="red").encode( + y=alt.Y("s_pos:Q", title="CUSUM statistic") + ) s_neg_line = base.mark_line(color="orange").encode(y=alt.Y("s_neg:Q")) - cusum_hover = make_nearest_hover( - df_steps, - ["s_pos", "s_neg", "h"], - {"s_pos": "red", "s_neg": "orange", "h": "green"}, - {"s_pos": "S+", "s_neg": "Sāˆ’", "h": "threshold (h)"}, - y_title="CUSUM statistic", - legend_title="CUSUM", + + # Unconditionally show both S+ and S- in one tooltip (rather than trying + # to guess which line the cursor is closer to), using a plain x-only + # "nearest" selection — cheap, unlike a 2D Voronoi nearest selection. + cusum_nearest = alt.selection_single( + nearest=True, on="mouseover", fields=["ts"], empty="none", name="nearest_cusum" ) + cusum_selectors = ( + base.mark_point() + .encode( + opacity=alt.value(0), + tooltip=[ + alt.Tooltip("ts:T", format=ts_tooltip_fmt), + alt.Tooltip("s_pos:Q", title="šŸ”“ S+"), + alt.Tooltip("s_neg:Q", title="🟠 Sāˆ’"), + ], + ) + .add_selection(cusum_nearest) + ) + cusum_hover_rule = base.mark_rule(color="gray").encode( + opacity=alt.condition(cusum_nearest, alt.value(0.3), alt.value(0)) + ) + s_pos_highlight = base.mark_point(color="red", filled=True, size=80).encode( + y=alt.Y("s_pos:Q"), + opacity=alt.condition(cusum_nearest, alt.value(1), alt.value(0)), + ) + s_neg_highlight = base.mark_point(color="orange", filled=True, size=80).encode( + y=alt.Y("s_neg:Q"), + opacity=alt.condition(cusum_nearest, alt.value(1), alt.value(0)), + ) + cusum_hover = ( + cusum_hover_rule + s_pos_highlight + s_neg_highlight + cusum_selectors + ) + threshold = ( alt.Chart(df_steps).mark_rule(color="green", strokeDash=[4, 4]).encode(y="h:Q") ) @@ -751,6 +773,9 @@ def make_nearest_hover(df, value_vars, colors, labels, y_title, legend_title): .mark_text(align="left", dx=5, fontSize=11, color="green") .encode(x="ts:T", y="h:Q", text=alt.value("threshold (h)")) ) + cusum_legend = make_legend_swatch( + ["S+", "Sāˆ’", "threshold (h)"], ["red", "orange", "green"], "CUSUM" + ) cusum_group = alt.layer( s_pos_line, s_neg_line, @@ -759,6 +784,7 @@ def make_nearest_hover(df, value_vars, colors, labels, y_title, legend_title): s_pos_label, s_neg_label, threshold_label, + cusum_legend, ) chart = ( From 3514a5b73b9b8581e87437dd51b581c4c9f62558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Fri, 7 Aug 2026 11:37:59 +0200 Subject: [PATCH 07/15] Add debugging dataframe mode; simplify chart code --- .../src/oonipipeline/analysis/detector.py | 86 ++++++------------- .../src/oonipipeline/events_panel/panel.py | 12 +++ 2 files changed, 40 insertions(+), 58 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index f3504730..eef63bc0 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -637,7 +637,7 @@ def make_cusums_chart(steps: List[CusumStep], block_type: str): def make_label(df, field, label, color): return ( alt.Chart(df) - .mark_text(align="left", dx=5, fontSize=11, color=color) + .mark_text(align="right", dx=-5, fontSize=11, color=color) .encode( x="ts:T", y=alt.Y(f"{field}:Q"), @@ -645,39 +645,6 @@ def make_label(df, field, label, color): ) ) - def make_nearest_hover(df, field, label, color, y_title, legend_title): - """ - Builds an invisible hit-target layer plus a visible highlight dot - for a single series. Uses a plain x-only "nearest" selection (cheap: - a sorted bisect) rather than a 2D Voronoi nearest selection, which - is what made panning/zooming sluggish. - """ - nearest = alt.selection_single( - nearest=True, on="mouseover", fields=["ts"], empty="none", name=f"nearest_{field}" - ) - hover_base = alt.Chart(df).encode( - x=alt.X("ts:T", axis=x_axis), - y=alt.Y(f"{field}:Q", title=y_title), - ) - selectors = ( - hover_base.mark_point() - .encode( - opacity=alt.value(0), - tooltip=[ - alt.Tooltip("ts:T", format=ts_tooltip_fmt), - alt.Tooltip(f"{field}:Q", title=label), - ], - ) - .add_selection(nearest) - ) - hover_rule = base.mark_rule(color="gray").encode( - opacity=alt.condition(nearest, alt.value(0.3), alt.value(0)) - ) - highlight_point = hover_base.mark_point( - color=color, filled=True, size=80 - ).encode(opacity=alt.condition(nearest, alt.value(1), alt.value(0))) - return hover_rule + highlight_point + selectors - def make_legend_swatch(labels, colors, title): """A zero-opacity mark whose sole purpose is to render a fixed-domain color legend, since the actual lines it labels use static (not @@ -697,14 +664,6 @@ def make_legend_swatch(labels, colors, title): obs_line = base.mark_line(color="steelblue", opacity=0.5).encode( y=alt.Y("obs_value:Q", title="value"), ) - obs_hover = make_nearest_hover( - df_steps, - "obs_value", - "observed", - "steelblue", - y_title="value", - legend_title="Value", - ) cp_points = ( alt.Chart(df_steps[df_steps["is_changepoint"]]) .mark_point(color="black", size=100, shape="diamond") @@ -720,46 +679,56 @@ def make_legend_swatch(labels, colors, title): ], ) ) - obs_label = make_label(df_last, "obs_value", "observed", "steelblue") + obs_last_value = df_last["obs_value"].iloc[0] + obs_label_text = ( + f"observed: {obs_last_value:.2f}" if pd.notna(obs_last_value) else "observed: n/a" + ) + obs_label = make_label(df_last, "obs_value", obs_label_text, "steelblue") obs_legend = make_legend_swatch(["observed"], ["steelblue"], "Value") - value_group = alt.layer(obs_line, obs_hover, cp_points, obs_label, obs_legend) s_pos_line = base.mark_line(color="red").encode( y=alt.Y("s_pos:Q", title="CUSUM statistic") ) s_neg_line = base.mark_line(color="orange").encode(y=alt.Y("s_neg:Q")) - # Unconditionally show both S+ and S- in one tooltip (rather than trying - # to guess which line the cursor is closer to), using a plain x-only + # One tooltip covering all three series at once (rather than trying to + # guess which line the cursor is closer to), using a plain x-only # "nearest" selection — cheap, unlike a 2D Voronoi nearest selection. - cusum_nearest = alt.selection_single( - nearest=True, on="mouseover", fields=["ts"], empty="none", name="nearest_cusum" + nearest = alt.selection_single( + nearest=True, on="mouseover", fields=["ts"], empty="none", name="nearest" ) - cusum_selectors = ( + selectors = ( base.mark_point() .encode( opacity=alt.value(0), tooltip=[ alt.Tooltip("ts:T", format=ts_tooltip_fmt), + alt.Tooltip("obs_value:Q", title="šŸ”µ observed"), alt.Tooltip("s_pos:Q", title="šŸ”“ S+"), alt.Tooltip("s_neg:Q", title="🟠 Sāˆ’"), ], ) - .add_selection(cusum_nearest) + .add_selection(nearest) + ) + hover_rule = base.mark_rule(color="gray").encode( + opacity=alt.condition(nearest, alt.value(0.3), alt.value(0)) ) - cusum_hover_rule = base.mark_rule(color="gray").encode( - opacity=alt.condition(cusum_nearest, alt.value(0.3), alt.value(0)) + obs_highlight = base.mark_point(color="steelblue", filled=True, size=80).encode( + y=alt.Y("obs_value:Q"), + opacity=alt.condition(nearest, alt.value(1), alt.value(0)), ) s_pos_highlight = base.mark_point(color="red", filled=True, size=80).encode( y=alt.Y("s_pos:Q"), - opacity=alt.condition(cusum_nearest, alt.value(1), alt.value(0)), + opacity=alt.condition(nearest, alt.value(1), alt.value(0)), ) s_neg_highlight = base.mark_point(color="orange", filled=True, size=80).encode( y=alt.Y("s_neg:Q"), - opacity=alt.condition(cusum_nearest, alt.value(1), alt.value(0)), + opacity=alt.condition(nearest, alt.value(1), alt.value(0)), ) - cusum_hover = ( - cusum_hover_rule + s_pos_highlight + s_neg_highlight + cusum_selectors + hover = hover_rule + selectors + + value_group = alt.layer( + obs_line, obs_highlight, cp_points, obs_label, obs_legend ) threshold = ( @@ -779,7 +748,8 @@ def make_legend_swatch(labels, colors, title): cusum_group = alt.layer( s_pos_line, s_neg_line, - cusum_hover, + s_pos_highlight, + s_neg_highlight, threshold, s_pos_label, s_neg_label, @@ -788,7 +758,7 @@ def make_legend_swatch(labels, colors, title): ) chart = ( - alt.layer(state_bands, value_group, cusum_group) + alt.layer(state_bands, value_group, cusum_group, hover) .resolve_scale(y="independent", color="independent") .resolve_legend(color="independent") .properties( diff --git a/oonipipeline/src/oonipipeline/events_panel/panel.py b/oonipipeline/src/oonipipeline/events_panel/panel.py index 712b838d..52c2da39 100644 --- a/oonipipeline/src/oonipipeline/events_panel/panel.py +++ b/oonipipeline/src/oonipipeline/events_panel/panel.py @@ -104,6 +104,15 @@ def detector_panel(): st.warning(f"No cusum steps found for ASN {selected_asn}") return + block_steps = [s for s in chart_steps if s["block_type"] == block_type] + if all(s["obs_value"] is None or s["obs_value"] != s["obs_value"] for s in block_steps): + st.info( + f"No **{block_type}** measurements were observed for this ASN in the " + "selected window (the underlying weight/count was zero every hour), so " + "the observed-value line has nothing to plot. The CUSUM statistics " + "below still reflect the detector's last known state." + ) + chart = make_cusums_chart(chart_steps, block_type) st.altair_chart(chart) @@ -122,5 +131,8 @@ def detector_panel(): if st.checkbox("Show Vega-Lite spec", key="debug_show_spec"): st.json(chart.to_dict()) + if st.checkbox("Show cusum data as dataframe", key="debug_show_cusum_df"): + st.dataframe(pd.DataFrame(block_steps)) + detector_panel() From 04cc7058f3ed710634bb1c23dce02b07c79c7e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Fri, 7 Aug 2026 13:58:01 +0200 Subject: [PATCH 08/15] Add all block_type charts into the same altair chart --- .../src/oonipipeline/analysis/detector.py | 108 +++++++++++++++--- .../src/oonipipeline/events_panel/panel.py | 76 ++++++++---- 2 files changed, 144 insertions(+), 40 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index eef63bc0..d06b6759 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -580,12 +580,37 @@ def run_detector_for( def plot(steps: List[CusumStep], block_type: str): make_cusums_chart(steps, block_type).show() -def make_cusums_chart(steps: List[CusumStep], block_type: str): +def make_cusums_chart( + steps: List[CusumStep], + block_type: str, + zoom=None, + show_legend: bool = True, + show_x_axis: bool = True, +): + """ + zoom: an optional shared alt.selection_interval(bind="scales") to reuse + across multiple calls (see make_cusums_chart_grid) so pan/zoom on one + chart's time axis applies to all of them. When omitted, a private one is + created so this function still works as a standalone chart. + + show_legend: when False, suppresses this chart's own State/Value/CUSUM + legends — used when stacking several of these charts so only one copy + of each (identically-scaled) legend is shown for the whole stack. + + show_x_axis: when False, hides the time-axis tick labels/ticks (grid + lines stay) — used when stacking several of these charts so only the + bottom-most one shows the shared time axis. + """ import altair as alt import pandas as pd STATE_COLORS = {"ok": "#d4edda", "blk": "#f8d7da", "unk": "#fff3cd"} - x_axis = alt.Axis(format="%b %d, %H:%M") + x_axis = alt.Axis( + format="%b %d, %H:%M", + title=None, + labels=show_x_axis, + ticks=show_x_axis, + ) ts_tooltip_fmt = "%b %d, %H:%M" df_steps = pd.DataFrame([s for s in steps if s["block_type"] == block_type]) @@ -622,7 +647,7 @@ def make_cusums_chart(steps: List[CusumStep], block_type: str): STATE_COLORS["unk"], ], ), - legend=alt.Legend(title="State"), + legend=alt.Legend(title="State") if show_legend else None, ), tooltip=[ "current_state:N", @@ -727,9 +752,10 @@ def make_legend_swatch(labels, colors, title): ) hover = hover_rule + selectors - value_group = alt.layer( - obs_line, obs_highlight, cp_points, obs_label, obs_legend - ) + value_group_layers = [obs_line, obs_highlight, cp_points, obs_label] + if show_legend: + value_group_layers.append(obs_legend) + value_group = alt.layer(*value_group_layers) threshold = ( alt.Chart(df_steps).mark_rule(color="green", strokeDash=[4, 4]).encode(y="h:Q") @@ -742,10 +768,7 @@ def make_legend_swatch(labels, colors, title): .mark_text(align="left", dx=5, fontSize=11, color="green") .encode(x="ts:T", y="h:Q", text=alt.value("threshold (h)")) ) - cusum_legend = make_legend_swatch( - ["S+", "Sāˆ’", "threshold (h)"], ["red", "orange", "green"], "CUSUM" - ) - cusum_group = alt.layer( + cusum_group_layers = [ s_pos_line, s_neg_line, s_pos_highlight, @@ -754,8 +777,17 @@ def make_legend_swatch(labels, colors, title): s_pos_label, s_neg_label, threshold_label, - cusum_legend, - ) + ] + if show_legend: + cusum_group_layers.append( + make_legend_swatch( + ["S+", "Sāˆ’", "threshold (h)"], ["red", "orange", "green"], "CUSUM" + ) + ) + cusum_group = alt.layer(*cusum_group_layers) + + if zoom is None: + zoom = alt.selection_interval(bind="scales", encodings=["x"]) chart = ( alt.layer(state_bands, value_group, cusum_group, hover) @@ -763,13 +795,59 @@ def make_legend_swatch(labels, colors, title): .resolve_legend(color="independent") .properties( width=900, - height=400, - title=f"CUSUM Detector: {block_type}", + height=100, + title=alt.TitleParams( + text=block_type, + fontSize=11, + fontWeight="normal", + color="gray", + anchor="start", + dy=-4, + offset=2, + ), ) - .interactive() + .add_selection(zoom) ) return chart + +def make_cusums_chart_grid(steps: List[CusumStep], block_types: List[str]): + """ + One row per block type, stacked vertically in a single chart, so all of + an ASN's CUSUM series can be compared at a glance. The State/Value/CUSUM + legends (identical across rows) are only shown once, on the last row, + and every row shares one time-axis pan/zoom selection. + """ + import altair as alt + + present_block_types = [ + block_type + for block_type in block_types + if any(s["block_type"] == block_type for s in steps) + ] + if not present_block_types: + return None + + zoom = alt.selection_interval(bind="scales", encodings=["x"]) + last_idx = len(present_block_types) - 1 + charts = [ + make_cusums_chart( + steps, + block_type, + zoom=zoom, + show_legend=(i == last_idx), + show_x_axis=(i == last_idx), + ) + for i, block_type in enumerate(present_block_types) + ] + return ( + alt.vconcat(*charts, spacing=0) + .resolve_scale(x="shared") + .properties(title="CUSUM Detector", padding=0) + .configure_view(strokeWidth=0) + ) + + def notify_slack( changepoints: list[Changepoint], slack_webhook: str, diff --git a/oonipipeline/src/oonipipeline/events_panel/panel.py b/oonipipeline/src/oonipipeline/events_panel/panel.py index 52c2da39..6a31ecd4 100644 --- a/oonipipeline/src/oonipipeline/events_panel/panel.py +++ b/oonipipeline/src/oonipipeline/events_panel/panel.py @@ -1,6 +1,10 @@ from collections import defaultdict import streamlit as st -from oonipipeline.analysis.detector import make_cusums_chart, run_detector_for, ANALYSIS_COLS +from oonipipeline.analysis.detector import ( + make_cusums_chart_grid, + run_detector_for, + ANALYSIS_COLS, +) from datetime import datetime, timezone, timedelta import logging import pandas as pd @@ -44,7 +48,11 @@ def detector_panel(): domain = c2.text_input("**domain**", "x.com") gap_halflife = c2.number_input("**Gap half life**", value=48.0) - warmup = st.checkbox("**Warmup**", True) + warmup = st.checkbox( + "**Warmup**", + False, + help="When enabled, no changepoints will be returned.", + ) submitted = st.form_submit_button("Run detector") # Only recompute on submit; store in session_state so results survive @@ -83,19 +91,30 @@ def detector_panel(): for step in cusum_steps: asns[step["probe_asn"]] += 1 + asns_with_changepoints = {cp["probe_asn"] for cp in changepoints} + c1, c2 = st.columns(2) c1.write(f"Changepoints: **{len(changepoints)}**") c2.write(f"Cusum steps: **{len(cusum_steps)}**") - c1, c2 = st.columns(2) - block_type = c1.selectbox( - "Block type", [c[0] for c in ANALYSIS_COLS], key="block_type_select" - ) + if changepoints: + st.write("**Changepoints**") + cp_df = pd.DataFrame(changepoints) + cp_df = cp_df.sort_values("ts", ascending=False).reset_index(drop=True) + display_cols = [ + c + for c in ["ts", "probe_asn", "domain", "block_type", "change_dir", "s_pos", "s_neg", "h"] + if c in cp_df.columns + ] + st.dataframe(cp_df[display_cols], hide_index=True) asn_list = list(asns.keys()) asn_list.sort(key=lambda k: asns[k], reverse=True) - selected_asn = c2.selectbox( - "ASN", asn_list, format_func=lambda k: f"{k} ({asns[k]})", key="asn_select" + selected_asn = st.selectbox( + "ASN", + asn_list, + format_func=lambda k: f"{'ā—ļø' if k in asns_with_changepoints else ''}{k} ({asns[k]})", + key="asn_select", ) chart_steps = [s for s in cusum_steps if s["probe_asn"] == selected_asn] @@ -104,24 +123,25 @@ def detector_panel(): st.warning(f"No cusum steps found for ASN {selected_asn}") return - block_steps = [s for s in chart_steps if s["block_type"] == block_type] - if all(s["obs_value"] is None or s["obs_value"] != s["obs_value"] for s in block_steps): - st.info( - f"No **{block_type}** measurements were observed for this ASN in the " - "selected window (the underlying weight/count was zero every hour), so " - "the observed-value line has nothing to plot. The CUSUM statistics " - "below still reflect the detector's last known state." - ) - - chart = make_cusums_chart(chart_steps, block_type) + block_types = [c[0] for c in ANALYSIS_COLS] + for block_type in block_types: + block_steps = [s for s in chart_steps if s["block_type"] == block_type] + if block_steps and all( + s["obs_value"] is None or s["obs_value"] != s["obs_value"] for s in block_steps + ): + st.info( + f"No **{block_type}** measurements were observed for this ASN in the " + "selected window (the underlying weight/count was zero every hour), so " + "the observed-value line has nothing to plot for that row. The CUSUM " + "statistics still reflect the detector's last known state." + ) + + chart = make_cusums_chart_grid(chart_steps, block_types) + if chart is None: + st.warning(f"No cusum steps found for ASN {selected_asn}") + return st.altair_chart(chart) - if asns: - df = pd.DataFrame({"ASN": list(asns.keys()), "total": list(asns.values())}) - df = df.sort_values("total", ascending=False).reset_index(drop=True) - df["ASN"] = df["ASN"].astype(str) - st.dataframe(df, hide_index=True) - with st.expander("šŸ”§ Debug"): if st.checkbox("Render chart as PNG", key="debug_render_png"): spec = chart.to_dict() @@ -132,7 +152,13 @@ def detector_panel(): st.json(chart.to_dict()) if st.checkbox("Show cusum data as dataframe", key="debug_show_cusum_df"): - st.dataframe(pd.DataFrame(block_steps)) + st.dataframe(pd.DataFrame(chart_steps)) + + if asns: + df = pd.DataFrame({"ASN": list(asns.keys()), "total": list(asns.values())}) + df = df.sort_values("total", ascending=False).reset_index(drop=True) + df["ASN"] = df["ASN"].astype(str) + st.dataframe(df, hide_index=True) detector_panel() From 24c63f8567a27e4bfd2ae000b8dc1f5b58041172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Fri, 7 Aug 2026 15:42:13 +0200 Subject: [PATCH 09/15] Run detector panel with query arguments; add link to detector panel in slack messages --- .../src/oonipipeline/analysis/detector.py | 21 ++++- .../src/oonipipeline/events_panel/panel.py | 77 ++++++++++++++++--- 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index d06b6759..71da4ae2 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -511,6 +511,7 @@ def run_detector( trace: bool = False, slack_webhook: str | None = None, explorer_base_url: str = "https://explorer.ooni.org/", + detector_panel_base_url: str = "https://detector-panel.prod.ooni.io/", ) -> Tuple[List[Changepoint], List[LastCusum], List[CusumStep]]: db = ClickhouseClient.from_url(clickhouse_url) domains = get_domain_list(db) @@ -536,7 +537,15 @@ def run_detector( update_tables(db, updated_cusums, changepoints) if slack_webhook is not None: - notify_slack(changepoints, slack_webhook, explorer_base_url) + notify_slack( + changepoints, + slack_webhook, + explorer_base_url, + detector_panel_base_url, + edd=edd, + gap_halflife=gap_halflife, + warmup=warmup, + ) return changepoints, updated_cusums, steps @@ -810,7 +819,6 @@ def make_legend_swatch(labels, colors, title): ) return chart - def make_cusums_chart_grid(steps: List[CusumStep], block_types: List[str]): """ One row per block type, stacked vertically in a single chart, so all of @@ -852,6 +860,10 @@ def notify_slack( changepoints: list[Changepoint], slack_webhook: str, explorer_base_url: str = "https://explorer.ooni.org/", + detector_panel_base_url: str = "https://detector-panel.prod.ooni.io/", + edd: int = 10, + gap_halflife: float = 48.0, + warmup: bool = False, ): """ Sends a message to slack with a list of all changepoints that were detected @@ -877,10 +889,13 @@ def dir_to_str(dir: int) -> str: explorer = get_explorer_url(cp, explorer_base_url) # Alerts panel not yet deployed to prod, we use the test one for now alerts = get_alert_page_url(cp, "https://explorer.test.ooni.org/") + panel = get_detector_panel_url( + cp, detector_panel_base_url, edd=edd, gap_halflife=gap_halflife, warmup=warmup + ) message += ( f"• :flag-{cp['probe_cc'].lower()}: [{cp['probe_cc']}/AS{cp['probe_asn']}] " f"*{cp['domain']}* {dir_to_str(cp['change_dir'])} - `{cp['block_type']}` " - f"| <{explorer}|explorer> | <{alerts}|alerts>\n" + f"| <{explorer}|explorer> | <{alerts}|alerts> | <{panel}|detector panel>\n" ) # Send messages in 10 entries batches to avoid max message size limit diff --git a/oonipipeline/src/oonipipeline/events_panel/panel.py b/oonipipeline/src/oonipipeline/events_panel/panel.py index 6a31ecd4..232b59e6 100644 --- a/oonipipeline/src/oonipipeline/events_panel/panel.py +++ b/oonipipeline/src/oonipipeline/events_panel/panel.py @@ -19,6 +19,36 @@ def run_detector_cached(*args, **kwargs): st.set_page_config(layout="wide") +# Query params that can prefill the form, e.g. +# ?probe_cc=VE&domain=x.com&start_time=2024-01-01T00:00:00&end_time=2024-01-15T00:00:00&edd=10&gap_halflife=48&warmup=false +# When every one of these is present (and parses cleanly), the form auto-runs +# on first load instead of waiting for a manual "Run detector" click. +QUERY_FIELD_TO_WIDGET_KEY = { + "probe_cc": "probe_cc_input", + "domain": "domain_input", + "start_time": "start_time_input", + "end_time": "end_time_input", + "edd": "edd_input", + "gap_halflife": "gap_halflife_input", + "warmup": "warmup_input", +} + + +def _parse_query_value(field: str, raw: str): + if field in ("start_time", "end_time"): + dt = datetime.fromisoformat(raw) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + if field == "edd": + return int(raw) + if field == "gap_halflife": + return float(raw) + if field == "warmup": + return raw.strip().lower() in ("1", "true", "yes") + return raw + + def detector_panel(): st.write( """ @@ -31,6 +61,23 @@ def detector_panel(): now = datetime.now(timezone.utc) + # Prefill the form from URL query params on first load, and remember + # whether every field was present so we can auto-run below. + if "query_params_applied" not in st.session_state: + st.session_state["query_params_applied"] = True + parsed_ok = set() + for field, widget_key in QUERY_FIELD_TO_WIDGET_KEY.items(): + raw = st.query_params.get(field) + if raw is not None: + try: + st.session_state[widget_key] = _parse_query_value(field, raw) + parsed_ok.add(field) + except (ValueError, TypeError): + pass + st.session_state["auto_submit_pending"] = parsed_ok == set( + QUERY_FIELD_TO_WIDGET_KEY + ) + clickhouse_url = st.sidebar.text_input( "**Clickhouse url**", "clickhouse://localhost:9000/ooni" ) @@ -39,25 +86,37 @@ def detector_panel(): c1, c2 = st.columns(2) # column 1 - start_time = c1.datetime_input("**Start date**", now - timedelta(days=30)) - probe_cc = c1.text_input("**Country code (two chars)**", "VE") - edd = c1.number_input("**Estimated Detection Delay (EDD)**", value=10) + start_time = c1.datetime_input( + "**Start date**", now - timedelta(days=30), key="start_time_input" + ) + probe_cc = c1.text_input( + "**Country code (two chars)**", "VE", key="probe_cc_input" + ) + edd = c1.number_input( + "**Estimated Detection Delay (EDD)**", value=10, key="edd_input" + ) # column2 - end_time = c2.datetime_input("**End date**", now) - domain = c2.text_input("**domain**", "x.com") - gap_halflife = c2.number_input("**Gap half life**", value=48.0) + end_time = c2.datetime_input("**End date**", now, key="end_time_input") + domain = c2.text_input("**domain**", "x.com", key="domain_input") + gap_halflife = c2.number_input( + "**Gap half life**", value=48.0, key="gap_halflife_input" + ) warmup = st.checkbox( "**Warmup**", False, help="When enabled, no changepoints will be returned.", + key="warmup_input", ) submitted = st.form_submit_button("Run detector") - # Only recompute on submit; store in session_state so results survive - # reruns triggered by the selectboxes below. - if submitted: + auto_submit = st.session_state.pop("auto_submit_pending", False) + + # Only recompute on submit (or on a fully-specified first load via query + # params); store in session_state so results survive reruns triggered by + # the selectboxes below. + if submitted or auto_submit: # Drop any cusum-related state left over from a previous submission for key in ("changepoints", "cusum_steps", "block_type_select", "asn_select"): st.session_state.pop(key, None) From 5cb97c4b1db323b210201a65af418faa361e78f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Fri, 7 Aug 2026 16:05:47 +0200 Subject: [PATCH 10/15] Add url parameters to event detector panel --- .../src/oonipipeline/analysis/detector.py | 34 +++++++++++++++++++ .../src/oonipipeline/events_panel/panel.py | 25 ++++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 71da4ae2..652dd558 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -953,3 +953,37 @@ def to_s(dt: datetime): } url = f"{base_url}/chart/alerts?{urlencode(params)}" return url + + +def get_detector_panel_url( + changepoint: Changepoint, + base_url: str = "https://detector-panel.prod.ooni.io/", + edd: int = 10, + gap_halflife: float = 48.0, + warmup: bool = False, +) -> str: + """ + Builds a link to the events-panel Streamlit app (see + oonipipeline.events_panel.panel), prefilled via query params so the app + auto-runs the detector for this changepoint's country/domain and a + +-13/2 day window around it on first load — same window convention as + get_explorer_url/get_alert_page_url above. + """ + start_time = changepoint["ts"] - timedelta(days=13) + end_time = changepoint["ts"] + timedelta(days=2) + + def to_iso(dt: datetime) -> str: + return dt.strftime("%Y-%m-%dT%H:%M:%S") + + params = { + "probe_cc": changepoint["probe_cc"], + "domain": changepoint["domain"], + "probe_asn": changepoint["probe_asn"], + "start_time": to_iso(start_time), + "end_time": to_iso(end_time), + "edd": edd, + "gap_halflife": gap_halflife, + "warmup": str(warmup).lower(), + } + url = f"{base_url}?{urlencode(params)}" + return url diff --git a/oonipipeline/src/oonipipeline/events_panel/panel.py b/oonipipeline/src/oonipipeline/events_panel/panel.py index 232b59e6..2010f35f 100644 --- a/oonipipeline/src/oonipipeline/events_panel/panel.py +++ b/oonipipeline/src/oonipipeline/events_panel/panel.py @@ -20,9 +20,12 @@ def run_detector_cached(*args, **kwargs): st.set_page_config(layout="wide") # Query params that can prefill the form, e.g. -# ?probe_cc=VE&domain=x.com&start_time=2024-01-01T00:00:00&end_time=2024-01-15T00:00:00&edd=10&gap_halflife=48&warmup=false +# ?probe_cc=VE&domain=x.com&start_time=2024-01-01T00:00:00&end_time=2024-01-15T00:00:00&edd=10&gap_halflife=48&warmup=false&probe_asn=1234 # When every one of these is present (and parses cleanly), the form auto-runs # on first load instead of waiting for a manual "Run detector" click. +# probe_asn isn't a form field — it's applied afterwards to preselect the ASN +# selectbox once results are in, since it's only known to be a valid choice +# after the detector has actually run. QUERY_FIELD_TO_WIDGET_KEY = { "probe_cc": "probe_cc_input", "domain": "domain_input", @@ -40,7 +43,7 @@ def _parse_query_value(field: str, raw: str): if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt - if field == "edd": + if field in ("edd", "probe_asn"): return int(raw) if field == "gap_halflife": return float(raw) @@ -78,6 +81,15 @@ def detector_panel(): QUERY_FIELD_TO_WIDGET_KEY ) + raw_probe_asn = st.query_params.get("probe_asn") + if raw_probe_asn is not None: + try: + st.session_state["query_probe_asn"] = _parse_query_value( + "probe_asn", raw_probe_asn + ) + except (ValueError, TypeError): + pass + clickhouse_url = st.sidebar.text_input( "**Clickhouse url**", "clickhouse://localhost:9000/ooni" ) @@ -169,6 +181,15 @@ def detector_panel(): asn_list = list(asns.keys()) asn_list.sort(key=lambda k: asns[k], reverse=True) + + query_probe_asn = st.session_state.get("query_probe_asn") + if ( + query_probe_asn is not None + and "asn_select" not in st.session_state + and query_probe_asn in asn_list + ): + st.session_state["asn_select"] = query_probe_asn + selected_asn = st.selectbox( "ASN", asn_list, From 6ab652b7cbf744676ee156fe099146e48b5d0707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Fri, 7 Aug 2026 16:07:10 +0200 Subject: [PATCH 11/15] Summarize comment --- oonipipeline/src/oonipipeline/analysis/detector.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 652dd558..4ac19130 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -963,11 +963,9 @@ def get_detector_panel_url( warmup: bool = False, ) -> str: """ - Builds a link to the events-panel Streamlit app (see + Builds a link to the events-panel app (see oonipipeline.events_panel.panel), prefilled via query params so the app - auto-runs the detector for this changepoint's country/domain and a - +-13/2 day window around it on first load — same window convention as - get_explorer_url/get_alert_page_url above. + auto-runs the detector for this changepoint's metadata """ start_time = changepoint["ts"] - timedelta(days=13) end_time = changepoint["ts"] + timedelta(days=2) From d0fcf2be27427c3b93d110a03737bfcb1d3cd05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Fri, 7 Aug 2026 16:26:40 +0200 Subject: [PATCH 12/15] Refactor make phighlights; simplify nan check --- .../src/oonipipeline/analysis/detector.py | 21 ++++++++----------- .../src/oonipipeline/events_panel/panel.py | 4 +--- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 4ac19130..d8933831 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -679,6 +679,12 @@ def make_label(df, field, label, color): ) ) + def make_highlight(field, color): + return base.mark_point(color=color, filled=True, size=80).encode( + y=alt.Y(f"{field}:Q"), + opacity=alt.condition(nearest, alt.value(1), alt.value(0)), + ) + def make_legend_swatch(labels, colors, title): """A zero-opacity mark whose sole purpose is to render a fixed-domain color legend, since the actual lines it labels use static (not @@ -747,18 +753,9 @@ def make_legend_swatch(labels, colors, title): hover_rule = base.mark_rule(color="gray").encode( opacity=alt.condition(nearest, alt.value(0.3), alt.value(0)) ) - obs_highlight = base.mark_point(color="steelblue", filled=True, size=80).encode( - y=alt.Y("obs_value:Q"), - opacity=alt.condition(nearest, alt.value(1), alt.value(0)), - ) - s_pos_highlight = base.mark_point(color="red", filled=True, size=80).encode( - y=alt.Y("s_pos:Q"), - opacity=alt.condition(nearest, alt.value(1), alt.value(0)), - ) - s_neg_highlight = base.mark_point(color="orange", filled=True, size=80).encode( - y=alt.Y("s_neg:Q"), - opacity=alt.condition(nearest, alt.value(1), alt.value(0)), - ) + obs_highlight = make_highlight("obs_value", "steelblue") + s_pos_highlight = make_highlight("s_pos", "red") + s_neg_highlight = make_highlight("s_neg", "orange") hover = hover_rule + selectors value_group_layers = [obs_line, obs_highlight, cp_points, obs_label] diff --git a/oonipipeline/src/oonipipeline/events_panel/panel.py b/oonipipeline/src/oonipipeline/events_panel/panel.py index 2010f35f..b24d291b 100644 --- a/oonipipeline/src/oonipipeline/events_panel/panel.py +++ b/oonipipeline/src/oonipipeline/events_panel/panel.py @@ -206,9 +206,7 @@ def detector_panel(): block_types = [c[0] for c in ANALYSIS_COLS] for block_type in block_types: block_steps = [s for s in chart_steps if s["block_type"] == block_type] - if block_steps and all( - s["obs_value"] is None or s["obs_value"] != s["obs_value"] for s in block_steps - ): + if block_steps and all(pd.isna(s["obs_value"]) for s in block_steps): st.info( f"No **{block_type}** measurements were observed for this ASN in the " "selected window (the underlying weight/count was zero every hour), so " From 4304a3fe04ed5ea50be984a132ffe313d529d58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Fri, 7 Aug 2026 16:51:26 +0200 Subject: [PATCH 13/15] optimize chart --- .../src/oonipipeline/analysis/detector.py | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index d8933831..5ff6662b 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -593,6 +593,7 @@ def make_cusums_chart( steps: List[CusumStep], block_type: str, zoom=None, + nearest=None, show_legend: bool = True, show_x_axis: bool = True, ): @@ -602,6 +603,12 @@ def make_cusums_chart( chart's time axis applies to all of them. When omitted, a private one is created so this function still works as a standalone chart. + nearest: an optional shared alt.selection_single(nearest=True, ...) to + reuse across multiple calls (see make_cusums_chart_grid), so a single + mouseover listener drives the hover crosshair/highlights on every row + instead of one independent listener per row. When omitted, a private one + is created so this function still works as a standalone chart. + show_legend: when False, suppresses this chart's own State/Value/CUSUM legends — used when stacking several of these charts so only one copy of each (identically-scaled) legend is shown for the whole stack. @@ -668,6 +675,14 @@ def make_cusums_chart( base = alt.Chart(df_steps).encode(x=alt.X("ts:T", axis=x_axis)) + # One tooltip covering all three series at once (rather than trying to + # guess which line the cursor is closer to), using a plain x-only + # "nearest" selection — cheap, unlike a 2D Voronoi nearest selection. + if nearest is None: + nearest = alt.selection_single( + nearest=True, on="mouseover", fields=["ts"], empty="none", name="nearest" + ) + def make_label(df, field, label, color): return ( alt.Chart(df) @@ -680,9 +695,13 @@ def make_label(df, field, label, color): ) def make_highlight(field, color): - return base.mark_point(color=color, filled=True, size=80).encode( - y=alt.Y(f"{field}:Q"), - opacity=alt.condition(nearest, alt.value(1), alt.value(0)), + # transform_filter (rather than an opacity condition over the full + # dataset) means only the single matched point is ever rendered, so + # this stays O(1) instead of O(n) marks per row. + return ( + base.transform_filter(nearest) + .mark_point(color=color, filled=True, size=80) + .encode(y=alt.Y(f"{field}:Q")) ) def make_legend_swatch(labels, colors, title): @@ -731,12 +750,6 @@ def make_legend_swatch(labels, colors, title): ) s_neg_line = base.mark_line(color="orange").encode(y=alt.Y("s_neg:Q")) - # One tooltip covering all three series at once (rather than trying to - # guess which line the cursor is closer to), using a plain x-only - # "nearest" selection — cheap, unlike a 2D Voronoi nearest selection. - nearest = alt.selection_single( - nearest=True, on="mouseover", fields=["ts"], empty="none", name="nearest" - ) selectors = ( base.mark_point() .encode( @@ -750,8 +763,8 @@ def make_legend_swatch(labels, colors, title): ) .add_selection(nearest) ) - hover_rule = base.mark_rule(color="gray").encode( - opacity=alt.condition(nearest, alt.value(0.3), alt.value(0)) + hover_rule = ( + base.transform_filter(nearest).mark_rule(color="gray", opacity=0.3) ) obs_highlight = make_highlight("obs_value", "steelblue") s_pos_highlight = make_highlight("s_pos", "red") @@ -834,12 +847,16 @@ def make_cusums_chart_grid(steps: List[CusumStep], block_types: List[str]): return None zoom = alt.selection_interval(bind="scales", encodings=["x"]) + nearest = alt.selection_single( + nearest=True, on="mouseover", fields=["ts"], empty="none", name="nearest" + ) last_idx = len(present_block_types) - 1 charts = [ make_cusums_chart( steps, block_type, zoom=zoom, + nearest=nearest, show_legend=(i == last_idx), show_x_axis=(i == last_idx), ) From 3864267b7dabe857f942716efc2a18e93e67b08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Mon, 10 Aug 2026 11:01:07 +0200 Subject: [PATCH 14/15] Summarize docs --- .../src/oonipipeline/analysis/detector.py | 11 +++++------ .../src/oonipipeline/events_panel/panel.py | 17 +++++------------ 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/oonipipeline/src/oonipipeline/analysis/detector.py b/oonipipeline/src/oonipipeline/analysis/detector.py index 5ff6662b..81389905 100644 --- a/oonipipeline/src/oonipipeline/analysis/detector.py +++ b/oonipipeline/src/oonipipeline/analysis/detector.py @@ -605,16 +605,15 @@ def make_cusums_chart( nearest: an optional shared alt.selection_single(nearest=True, ...) to reuse across multiple calls (see make_cusums_chart_grid), so a single - mouseover listener drives the hover crosshair/highlights on every row - instead of one independent listener per row. When omitted, a private one - is created so this function still works as a standalone chart. + mouseover listener drives the hover crosshair/highlights on every row. + When omitted, a private one is created show_legend: when False, suppresses this chart's own State/Value/CUSUM - legends — used when stacking several of these charts so only one copy - of each (identically-scaled) legend is shown for the whole stack. + legends. Used when stacking several of these charts so only one copy + of each legend is shown for the whole stack. show_x_axis: when False, hides the time-axis tick labels/ticks (grid - lines stay) — used when stacking several of these charts so only the + lines stay). Used when stacking several of these charts so only the bottom-most one shows the shared time axis. """ import altair as alt diff --git a/oonipipeline/src/oonipipeline/events_panel/panel.py b/oonipipeline/src/oonipipeline/events_panel/panel.py index b24d291b..83c7f6f1 100644 --- a/oonipipeline/src/oonipipeline/events_panel/panel.py +++ b/oonipipeline/src/oonipipeline/events_panel/panel.py @@ -19,13 +19,8 @@ def run_detector_cached(*args, **kwargs): st.set_page_config(layout="wide") -# Query params that can prefill the form, e.g. -# ?probe_cc=VE&domain=x.com&start_time=2024-01-01T00:00:00&end_time=2024-01-15T00:00:00&edd=10&gap_halflife=48&warmup=false&probe_asn=1234 -# When every one of these is present (and parses cleanly), the form auto-runs -# on first load instead of waiting for a manual "Run detector" click. -# probe_asn isn't a form field — it's applied afterwards to preselect the ASN -# selectbox once results are in, since it's only known to be a valid choice -# after the detector has actually run. +# When every one of these is present the form auto-runs +# on first load QUERY_FIELD_TO_WIDGET_KEY = { "probe_cc": "probe_cc_input", "domain": "domain_input", @@ -64,8 +59,7 @@ def detector_panel(): now = datetime.now(timezone.utc) - # Prefill the form from URL query params on first load, and remember - # whether every field was present so we can auto-run below. + # Prefill the form from URL query params on first load if "query_params_applied" not in st.session_state: st.session_state["query_params_applied"] = True parsed_ok = set() @@ -125,11 +119,10 @@ def detector_panel(): auto_submit = st.session_state.pop("auto_submit_pending", False) - # Only recompute on submit (or on a fully-specified first load via query - # params); store in session_state so results survive reruns triggered by + # store in session_state so results survive reruns triggered by # the selectboxes below. if submitted or auto_submit: - # Drop any cusum-related state left over from a previous submission + # Drop any cusum-related state left over for key in ("changepoints", "cusum_steps", "block_type_select", "asn_select"): st.session_state.pop(key, None) From 79917551144c8a965c8ae9e3d573dd80502ea549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20D=C3=ADaz?= Date: Mon, 10 Aug 2026 11:08:15 +0200 Subject: [PATCH 15/15] omit panel from code coverage; not useful to cover --- oonipipeline/pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/oonipipeline/pyproject.toml b/oonipipeline/pyproject.toml index 2e66f9c1..8fde5e99 100644 --- a/oonipipeline/pyproject.toml +++ b/oonipipeline/pyproject.toml @@ -82,6 +82,11 @@ test-cov = "pytest --cov=./ --cov-report=xml --cov-report=html --cov-report=term cov-report = ["coverage report"] cov = ["test-cov", "cov-report"] +[tool.coverage.run] +omit = [ + "src/oonipipeline/events_panel/panel.py", +] + [tool.hatch.envs.analysis] template = "default" features = ["analysis"]