Skip to content
Draft
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
56 changes: 56 additions & 0 deletions src/ess/livedata/dashboard/correlation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2026 Scipp contributors (https://github.com/scipp)
"""Correlation of time series with the values of other time series."""

from __future__ import annotations

from collections.abc import Mapping

import scipp as sc


def correlate[K](
data: Mapping[K, sc.DataArray], axes: Mapping[str, sc.DataArray]
) -> dict[K, sc.DataArray]:
"""Add to each point of ``data`` the value every axis had at that time.

Each point is correlated with the axis value in effect at its timestamp, i.e.
the most recent axis reading at or before it. Points predating the first
reading of any axis have no such value and are dropped; correlating them with
a reading taken later would be fabricating the axis history.

Parameters
----------
data:
Time series to correlate, each with a ``time`` dim and coord.
axes:
Axis time series, keyed by the name of the coord they become in the
result. Their ``time`` coord must use the same dtype and unit as that of
``data``.

Returns
-------
:
The correlated time series, keyed like ``data``. Entries left without
points are omitted.
"""
# sc.values only accepts float dtypes, so integer axes are used as is.
lookups = {
name: sc.lookup(
sc.values(ax) if ax.variances is not None else ax, mode='previous'
)
for name, ax in axes.items()
}
# Earliest time at which every axis has a reading. Before it, 'previous'
# lookup yields NaN, which hist()/bin() would drop without a trace.
start = max(ax.coords['time'].min() for ax in axes.values())

correlated: dict[K, sc.DataArray] = {}
for key, da in data.items():
dependent = da['time', start:].copy(deep=False)
if dependent.sizes['time'] == 0:
continue
for name, lut in lookups.items():
dependent.coords[name] = lut[dependent.coords['time']]
correlated[key] = dependent
return correlated
31 changes: 5 additions & 26 deletions src/ess/livedata/dashboard/correlation_plotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from ess.livedata.config.workflow_spec import DataKey

from .correlation import correlate
from .data_roles import PRIMARY, X_AXIS, Y_AXIS
from .plot_params import (
LegendPosition,
Expand Down Expand Up @@ -187,12 +188,9 @@ class CorrelationHistogramPlotter:
- "primary": dict[DataKey, DataArray] - data to histogram
- One or more axis roles (e.g., "x_axis", "y_axis") containing correlation values

Each point of the primary data is correlated with the axis value in effect at
its timestamp, i.e. the most recent axis reading at or before it. Points
predating the first reading of any axis have no such value and are excluded
from the histogram; correlating them with a reading taken later would be
fabricating the axis history. The plot therefore starts empty until the axes
and the data overlap in time.
The primary data is correlated with the axes by :func:`correlate`, so points
predating the first reading of any axis are excluded from the histogram. The
plot therefore starts empty until the axes and the data overlap in time.
"""

AUTOSCALE_AXES: ClassVar[frozenset[Axis]] = frozenset()
Expand Down Expand Up @@ -251,27 +249,8 @@ def compute(
)
axis_data[axis.name] = ax

# sc.values only accepts float dtypes, so integer axes are used as is.
lookups = {
name: sc.lookup(
sc.values(ax) if ax.variances is not None else ax, mode='previous'
)
for name, ax in axis_data.items()
}
# Earliest time at which every axis has a reading. Before it, 'previous'
# lookup yields NaN, which hist()/bin() would drop without a trace.
start = max(ax.coords['time'].min() for ax in axis_data.values())

histograms: dict[DataKey, sc.DataArray] = {}
for key, source_data in histogram_data.items():
dependent = source_data['time', start:].copy(deep=False)
if dependent.sizes['time'] == 0:
continue

# Add all axis coordinates via lookup
for name, lut in lookups.items():
dependent.coords[name] = lut[dependent.coords['time']]

for key, dependent in correlate(histogram_data, axis_data).items():
bin_spec = {
axis.name: _axis_bins(dependent.coords[axis.name], axis)
for axis in self._axes
Expand Down
66 changes: 59 additions & 7 deletions src/ess/livedata/dashboard/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import logging
from abc import ABC, abstractmethod
from collections.abc import Callable, Sequence
from contextlib import ExitStack
from pathlib import Path

Expand All @@ -25,6 +26,7 @@
from .session_updater import SessionUpdater
from .theme import DEFAULT_THEME, THEMES, Theme
from .transport import NullTransport, Transport
from .widgets.data_export_modal import DataExportLauncher
from .widgets.styles import PhoneLayout

# Bokeh's own reaper, distinct from the registry's: seconds an unused Bokeh
Expand All @@ -38,6 +40,24 @@
_LOGIN_TEMPLATE = str(_TEMPLATES_DIR / 'login.html')
_LOGOUT_TEMPLATE = str(_TEMPLATES_DIR / 'logout.html')

# Matches the logout link: an outlined pill in the header's text color.
_HEADER_BUTTON_CSS = """
:host .bk-btn {
color: white;
background: none;
border: 1.5px solid rgba(255, 255, 255, 0.7);
border-radius: 20px;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.5px;
padding: 6px 18px;
}
:host .bk-btn:hover {
background: rgba(255, 255, 255, 0.2);
border-color: white;
}
"""


def _phone_template_css(theme: Theme) -> str:
"""Page rules for the phone layout (``?layout=phone``).
Expand Down Expand Up @@ -191,7 +211,11 @@ def create_sidebar_content(

@abstractmethod
def create_main_content(
self, session_updater: SessionUpdater, *, phone: bool
self,
session_updater: SessionUpdater,
*,
phone: bool,
overlays: Sequence[pn.viewable.Viewable],
) -> pn.viewable.Viewable:
"""
Override this method to create the main dashboard content.
Expand All @@ -204,6 +228,10 @@ def create_main_content(
in their constructor.
phone:
Whether this session asked for the phone layout (``?layout=phone``).
overlays:
Zero-height holders of modals opened from the header, to be placed
inside the main content: wrapping it to add them overflows the page,
and in the header the modals' content can stay invisible (#1154).
"""

def get_dashboard_title(self) -> str:
Expand Down Expand Up @@ -283,8 +311,25 @@ def _cleanup_session(session_context):
pn.state.on_session_destroyed(_cleanup_session)
self._logger.info("Periodic updates started for session %s", session_id)

def _create_logout_header(self) -> list[pn.viewable.Viewable]:
"""Create a logout button for the header when auth is enabled."""
def _create_header(
self, on_export: Callable[[], None]
) -> list[pn.viewable.Viewable]:
"""Create the header items: data export, and logout when auth is enabled."""
export_button = pn.widgets.Button(
label='Export data',
description='Save buffered data to a file',
stylesheets=[_HEADER_BUTTON_CSS],
css_classes=['lt-export-data'],
margin=(0, 8),
)
export_button.on_click(lambda _: on_export())
header = [pn.layout.HSpacer(), export_button]
if self._basic_auth_password:
header.append(self._create_logout_link())
return header

def _create_logout_link(self) -> pn.pane.HTML:
"""Create a logout button for the header."""
logout_link = pn.pane.HTML(
"""<div style="text-align: right; padding-right: 8px;">
<a href="/logout" style="
Expand All @@ -304,9 +349,8 @@ def _create_logout_header(self) -> list[pn.viewable.Viewable]:
onmouseout="this.style.background='none';
this.style.borderColor='rgba(255,255,255,0.7)'"
>Log out</a></div>""",
sizing_mode='stretch_width',
)
return [logout_link]
return logout_link

def create_layout(self) -> pn.template.MaterialTemplate:
"""Create the basic dashboard layout."""
Expand Down Expand Up @@ -345,8 +389,16 @@ def _build_layout(self) -> pn.template.MaterialTemplate:
# screen size, and a session never switches between layouts.
phone = pn.state.session_args.get('layout') == [b'phone']

export_launcher = DataExportLauncher(
data_service=self._services.data_service,
workflow_registry=self._services.job_orchestrator.get_workflow_registry(),
instrument=self._instrument,
instrument_config=self._services.instrument_config,
)
sidebar_content = self.create_sidebar_content(session_updater)
main_content = self.create_main_content(session_updater, phone=phone)
main_content = self.create_main_content(
session_updater, phone=phone, overlays=[export_launcher.panel]
)

# Append heartbeat widget to sidebar (invisible but required for
# browser heartbeat JavaScript to run). Placing it in the sidebar
Expand All @@ -363,7 +415,7 @@ def _build_layout(self) -> pn.template.MaterialTemplate:
session_updater.heartbeat_widget,
)

header = self._create_logout_header() if self._basic_auth_password else []
header = self._create_header(on_export=export_launcher.open)

template = pn.template.MaterialTemplate(
title=self.get_dashboard_title(),
Expand Down
8 changes: 8 additions & 0 deletions src/ess/livedata/dashboard/dashboard_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from .active_job_registry import ActiveJobRegistry
from .command_service import CommandService
from .config_store import ConfigStoreManager
from .data_export import TimeseriesRetention, timeseries_keys
from .data_service import DataService
from .frame_clock import FrameClock
from .job_orchestrator import JobOrchestrator
Expand Down Expand Up @@ -125,6 +126,13 @@ def __init__(
# Setup all services
self._setup_data_infrastructure()
self._setup_workflow_management()
# Time series keep their history whether or not they are plotted, so it
# can be exported and correlated from the start of the buffer.
self.data_service.register_subscriber(
TimeseriesRetention(
timeseries_keys(self.job_orchestrator.get_workflow_registry())
)
)
self._setup_plot_orchestrator()

logger.info("DashboardServices initialized for %s", instrument)
Expand Down
Loading
Loading