From 045d31110272d38efa9bd8864e84f7306f0d57f9 Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Tue, 24 Feb 2026 16:35:33 -0800 Subject: [PATCH 01/17] initial generation of svg for EEG --- eegvis/stackplot_svg.py | 381 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 6 + tests/test_stackplot_svg.py | 175 +++++++++++++++++ 3 files changed, 562 insertions(+) create mode 100644 eegvis/stackplot_svg.py create mode 100644 tests/test_stackplot_svg.py diff --git a/eegvis/stackplot_svg.py b/eegvis/stackplot_svg.py new file mode 100644 index 0000000..239ed1c --- /dev/null +++ b/eegvis/stackplot_svg.py @@ -0,0 +1,381 @@ +# -*- coding: utf-8 -*- +"""Pure SVG backend for EEG stack plotting. + +Generates standalone SVG files from EEG signal data without requiring +matplotlib. Uses xml.etree.ElementTree for SVG construction. + +The coordinate system follows clinical EEG convention where negative +voltages are displayed upward. SVG's native y-down axis makes this +straightforward: raw signal values map directly so that negative +deflections appear as upward movements on screen. +""" + +import numpy as np +import xml.etree.ElementTree as ET + + +SVG_NS = "http://www.w3.org/2000/svg" + +# default styling +DEFAULT_TRACE_COLOR = "black" +DEFAULT_TRACE_WIDTH = "0.5" +DEFAULT_FONT_FAMILY = "sans-serif" +DEFAULT_FONT_SIZE = 10 # in SVG user units (roughly px) +DEFAULT_GRID_COLOR = "#cccccc" +DEFAULT_GRID_WIDTH = "0.3" + + +def _format_points(t, y): + """Convert time and amplitude arrays to SVG polyline points string. + + Rounds to 2 decimal places to keep file size reasonable. + Uses numpy vectorized string formatting for performance. + """ + coords = np.column_stack((np.round(t, 2), np.round(y, 2))) + return " ".join(f"{x},{y}" for x, y in coords) + + +def _compute_channel_offsets(data, num_channels, sensitivity=None, height=None): + """Compute vertical offset for each channel. + + Args: + data: (num_samples, num_channels) array + num_channels: number of channels + sensitivity: if set, absolute spacing in data units per channel + height: total plot height in SVG units (used with sensitivity) + + Returns: + ticklocs: list of y-offsets for each channel + dr: spacing between channels + """ + ch_indices = np.arange(num_channels, dtype=float) + if sensitivity is not None and height is not None: + # absolute sensitivity mode: distribute channels evenly across height + dr = sensitivity * height / num_channels + ticklocs = ch_indices * dr + dr / 2.0 + else: + # auto mode: space based on data range + dr = (data.max() - data.min()) * 0.7 + ticklocs = ch_indices * dr + return ticklocs, dr + + +def stackplot_svg( + signals, + sample_frequency, + ylabels=None, + seconds=None, + start_time=0.0, + yscale=1.0, + sensitivity=None, + width_mm=300, + height_mm=200, + topdown=True, + linecolor=None, + linewidth=None, + grid_interval=1.0, + show_scalebar=True, + scalebar_height=None, + scalebar_units="\u00b5V", +): + """Generate an SVG string of stacked EEG traces. + + Args: + signals: (num_channels, num_samples) numpy array of signal data + sample_frequency: sampling rate in Hz + ylabels: list of channel label strings + seconds: duration to display (defaults to full signal duration) + start_time: time offset for x-axis labels in seconds + yscale: gain multiplier (scalar or per-channel array) + sensitivity: absolute sensitivity in data_units/mm (e.g. 7 for 7 uV/mm). + Overrides auto-scaling. + width_mm: SVG width in mm + height_mm: SVG height in mm + topdown: if True, first channel appears at top + linecolor: trace color (default: black) + linewidth: trace stroke width + grid_interval: time interval for vertical grid lines in seconds. + Set to None to disable grid. + show_scalebar: whether to draw a vertical scale bar + scalebar_height: height of scale bar in data units. + If None, auto-computed as ~10% of channel spacing. + scalebar_units: units label for scale bar (default: µV) + + Returns: + SVG content as a string + """ + num_channels, num_samples = signals.shape + + if seconds is None: + seconds = num_samples / sample_frequency + + if ylabels is None: + ylabels = [str(i) for i in range(num_channels)] + + if linecolor is None: + linecolor = DEFAULT_TRACE_COLOR + if linewidth is None: + linewidth = DEFAULT_TRACE_WIDTH + + # layout constants + label_margin = 60 # left margin for channel labels + top_margin = 15 + bottom_margin = 25 # space for time axis labels + right_margin = 20 + scalebar_margin = 50 if show_scalebar else 0 + + plot_width = width_mm - label_margin - right_margin - scalebar_margin + plot_height = height_mm - top_margin - bottom_margin + + # transpose to (num_samples, num_channels) for processing + data = signals.T + + # compute vertical offsets in data space + ticklocs, dr = _compute_channel_offsets( + yscale * data, num_channels, sensitivity=sensitivity, height=plot_height + ) + + def time_to_x(t): + """Map time value(s) to SVG x-coordinate(s). Accepts scalars or arrays.""" + return label_margin + (t - start_time) / seconds * plot_width + + # compute mapping from data coordinates to SVG coordinates + t_data = start_time + seconds * np.arange(num_samples, dtype=float) / max(num_samples - 1, 1) + t_svg = time_to_x(t_data) + + # y axis: data values are offset by ticklocs, then mapped to SVG y + # In SVG, y increases downward which gives us "negative is up" for free + if sensitivity is not None: + y_data_min = 0.0 + y_data_max = sensitivity * plot_height + else: + scaled = yscale * data + y_data_min = scaled.min() + y_data_max = (num_channels - 1) * dr + scaled.max() + + y_data_range = y_data_max - y_data_min + + def data_y_to_svg(y_data): + """Map data y-coordinate(s) to SVG y-coordinate(s). + + Accepts scalars or numpy arrays. + """ + if y_data_range == 0: + return top_margin + 0.5 * plot_height + return top_margin + (y_data - y_data_min) / y_data_range * plot_height + + # channel ordering + channel_order = list(range(num_channels)) + label_order = list(ylabels) + if topdown: + channel_order = list(reversed(channel_order)) + label_order = list(reversed(label_order)) + + # build SVG + svg = ET.Element("svg", { + "xmlns": SVG_NS, + "viewBox": f"0 0 {width_mm} {height_mm}", + "width": f"{width_mm}mm", + "height": f"{height_mm}mm", + }) + + # white background + ET.SubElement(svg, "rect", { + "width": "100%", + "height": "100%", + "fill": "white", + }) + + # style element for text defaults + style = ET.SubElement(svg, "style") + style.text = ( + f"text {{ font-family: {DEFAULT_FONT_FAMILY}; font-size: {DEFAULT_FONT_SIZE}px; }}" + f" .label {{ text-anchor: end; dominant-baseline: middle; }}" + f" .time-label {{ text-anchor: middle; dominant-baseline: hanging; }}" + f" .scalebar-label {{ text-anchor: start; dominant-baseline: middle; }}" + ) + + # vertical grid lines at regular time intervals + if grid_interval is not None: + grid_g = ET.SubElement(svg, "g", {"class": "grid"}) + grid_times = np.arange( + np.ceil(start_time / grid_interval) * grid_interval, + start_time + seconds + grid_interval * 0.01, # small epsilon for inclusive end + grid_interval, + ) + grid_times = grid_times[grid_times <= start_time + seconds] + for t in grid_times: + x = time_to_x(t) + ET.SubElement(grid_g, "line", { + "x1": f"{x:.2f}", + "y1": f"{top_margin:.2f}", + "x2": f"{x:.2f}", + "y2": f"{top_margin + plot_height:.2f}", + "stroke": DEFAULT_GRID_COLOR, + "stroke-width": DEFAULT_GRID_WIDTH, + }) + + # plot border + ET.SubElement(svg, "rect", { + "x": f"{label_margin:.2f}", + "y": f"{top_margin:.2f}", + "width": f"{plot_width:.2f}", + "height": f"{plot_height:.2f}", + "fill": "none", + "stroke": "#999999", + "stroke-width": "0.5", + }) + + # channel traces and labels + traces_g = ET.SubElement(svg, "g", {"class": "traces"}) + for draw_idx, ch_idx in enumerate(channel_order): + offset = ticklocs[draw_idx] + y_trace = yscale * data[:, ch_idx] + offset + y_svg = data_y_to_svg(y_trace) + + ch_g = ET.SubElement(traces_g, "g", { + "class": "channel", + "id": f"ch-{draw_idx}", + }) + + # channel label + label_y = data_y_to_svg(offset) + ET.SubElement(ch_g, "text", { + "x": f"{label_margin - 4:.2f}", + "y": f"{label_y:.2f}", + "class": "label", + }).text = label_order[draw_idx] + + # polyline for waveform + points_str = _format_points(t_svg, y_svg) + ET.SubElement(ch_g, "polyline", { + "points": points_str, + "fill": "none", + "stroke": linecolor, + "stroke-width": str(linewidth), + }) + + # time axis labels + time_g = ET.SubElement(svg, "g", {"class": "timeaxis"}) + time_label_y = top_margin + plot_height + 5 + + if grid_interval is not None: + label_times = grid_times + else: + label_times = [start_time, start_time + seconds] + + for t in label_times: + x = time_to_x(t) + ET.SubElement(time_g, "text", { + "x": f"{x:.2f}", + "y": f"{time_label_y:.2f}", + "class": "time-label", + }).text = f"{t:.4g}s" + + # vertical scale bar + if show_scalebar: + if scalebar_height is None: + # auto: use ~10% of channel spacing, rounded to 1 significant digit + scalebar_height = dr * 0.5 + scalebar_height = float(f"{scalebar_height:.1g}") + + sb_x = label_margin + plot_width + 15 + # center the scale bar vertically in the plot + sb_center_data = (y_data_min + y_data_max) / 2.0 + sb_top_data = sb_center_data - scalebar_height / 2.0 + sb_bot_data = sb_center_data + scalebar_height / 2.0 + + sb_top_svg = data_y_to_svg(sb_top_data) + sb_bot_svg = data_y_to_svg(sb_bot_data) + + sb_g = ET.SubElement(svg, "g", {"class": "scalebar"}) + # vertical line + ET.SubElement(sb_g, "line", { + "x1": f"{sb_x:.2f}", + "y1": f"{sb_top_svg:.2f}", + "x2": f"{sb_x:.2f}", + "y2": f"{sb_bot_svg:.2f}", + "stroke": "black", + "stroke-width": "1", + }) + # top end cap + cap_w = 3 + ET.SubElement(sb_g, "line", { + "x1": f"{sb_x - cap_w:.2f}", + "y1": f"{sb_top_svg:.2f}", + "x2": f"{sb_x + cap_w:.2f}", + "y2": f"{sb_top_svg:.2f}", + "stroke": "black", + "stroke-width": "1", + }) + # bottom end cap + ET.SubElement(sb_g, "line", { + "x1": f"{sb_x - cap_w:.2f}", + "y1": f"{sb_bot_svg:.2f}", + "x2": f"{sb_x + cap_w:.2f}", + "y2": f"{sb_bot_svg:.2f}", + "stroke": "black", + "stroke-width": "1", + }) + # label + sb_label_y = (sb_top_svg + sb_bot_svg) / 2.0 + ET.SubElement(sb_g, "text", { + "x": f"{sb_x + cap_w + 3:.2f}", + "y": f"{sb_label_y:.2f}", + "class": "scalebar-label", + }).text = f"{scalebar_height:.4g}{scalebar_units}" + + # serialize + ET.indent(svg, space=" ") + return ET.tostring(svg, encoding="unicode", xml_declaration=True) + + +def save_svg(filepath, signals, sample_frequency, **kwargs): + """Render stacked EEG traces and write to an SVG file. + + Args: + filepath: output file path + signals: (num_channels, num_samples) numpy array + sample_frequency: sampling rate in Hz + **kwargs: passed to stackplot_svg() + """ + svg_str = stackplot_svg(signals, sample_frequency, **kwargs) + with open(filepath, "w", encoding="utf-8") as f: + f.write(svg_str) + + +def show_montage_svg(signals, montage, sample_frequency, **kwargs): + """Apply a montage derivation and render as SVG. + + Args: + signals: raw signals (num_channels, num_samples) numpy array + montage: a MontageView instance with .V.data matrix and .montage_labels + sample_frequency: sampling rate in Hz + **kwargs: passed to stackplot_svg() + + Returns: + SVG content as a string + """ + derived = np.dot(montage.V.data, signals) + labels = montage.montage_labels + return stackplot_svg( + derived, + sample_frequency, + ylabels=labels, + **kwargs, + ) + + +def save_montage_svg(filepath, signals, montage, sample_frequency, **kwargs): + """Apply a montage derivation and write SVG to file. + + Args: + filepath: output file path + signals: raw signals (num_channels, num_samples) numpy array + montage: a MontageView instance + sample_frequency: sampling rate in Hz + **kwargs: passed to stackplot_svg() + """ + svg_str = show_montage_svg(signals, montage, sample_frequency, **kwargs) + with open(filepath, "w", encoding="utf-8") as f: + f.write(svg_str) diff --git a/pyproject.toml b/pyproject.toml index fb1a817..8391a36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,3 +39,9 @@ pyedflib = ["pyedflib<=0.1.22"] # sample_frequency is wrong after this will be f [project.urls] Home = "https://github.com/eegml/eegvis" + +[dependency-groups] +dev = [ + "ipykernel>=7.2.0", + "pytest>=9.0.2", +] diff --git a/tests/test_stackplot_svg.py b/tests/test_stackplot_svg.py new file mode 100644 index 0000000..8e91b0e --- /dev/null +++ b/tests/test_stackplot_svg.py @@ -0,0 +1,175 @@ +import numpy as np +import xml.etree.ElementTree as ET +import eegvis.stackplot_svg as stackplot_svg + + +def make_sine_signals(num_channels=5, num_samples=800, fs=256.0): + """Generate multi-channel sine wave test data at different frequencies.""" + t = np.arange(num_samples) / fs + signals = np.zeros((num_channels, num_samples)) + for i in range(num_channels): + freq = 2 + i * 3 # 2, 5, 8, 11, 14 Hz + signals[i, :] = 50.0 * np.sin(2 * np.pi * freq * t) + return signals + + +def make_calibration_signal(num_samples, levels=None): + if levels is None: + levels = [0, 10, -10, 10, -10] + interval = num_samples // len(levels) + data = np.zeros(num_samples) + for ii, level in enumerate(levels): + data[ii * interval : (ii + 1) * interval] = level + return data + + +def test_stackplot_svg_returns_valid_svg(): + """Verify returned string is parseable SVG with expected structure.""" + signals = np.random.randn(5, 800) + svg_str = stackplot_svg.stackplot_svg(signals, sample_frequency=256.0, seconds=3.0) + + # must be parseable XML + root = ET.fromstring(svg_str) + assert root.tag == f"{{{stackplot_svg.SVG_NS}}}svg" + + # should have channel groups + ns = {"svg": stackplot_svg.SVG_NS} + channels = root.findall(".//svg:g[@class='channel']", ns) + assert len(channels) == 5 + + +def test_stackplot_svg_channel_labels(): + """Channel labels should appear as text elements.""" + labels = ["Fp1-F7", "F7-T3", "T3-T5", "T5-O1"] + signals = np.random.randn(4, 500) + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, ylabels=labels + ) + + for label in labels: + assert label in svg_str + + +def test_stackplot_svg_with_sensitivity(): + """Sensitivity mode should produce valid SVG.""" + signals = np.random.randn(5, 800) + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, seconds=3.0, sensitivity=7.0 + ) + root = ET.fromstring(svg_str) + assert root.tag == f"{{{stackplot_svg.SVG_NS}}}svg" + + +def test_stackplot_svg_no_grid(): + """Disabling grid should produce SVG without grid group.""" + signals = np.random.randn(3, 300) + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, grid_interval=None + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + grid = root.findall(".//svg:g[@class='grid']", ns) + assert len(grid) == 0 + + +def test_stackplot_svg_no_scalebar(): + """Disabling scalebar should produce SVG without scalebar group.""" + signals = np.random.randn(3, 300) + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, show_scalebar=False + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + scalebar = root.findall(".//svg:g[@class='scalebar']", ns) + assert len(scalebar) == 0 + + +def test_stackplot_svg_scalebar_present(): + """Default should include scalebar with unit text.""" + signals = np.random.randn(5, 800) + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, seconds=3.0 + ) + assert "\u00b5V" in svg_str # µV + + +def test_stackplot_svg_time_labels(): + """Time axis labels should reflect start_time and duration.""" + signals = np.random.randn(3, 512) + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, seconds=2.0, start_time=5.0 + ) + # should contain time labels like "5s", "6s", "7s" + assert "5s" in svg_str + assert "6s" in svg_str + assert "7s" in svg_str + + +def test_save_svg(tmp_path): + """save_svg should write a file that is valid SVG.""" + filepath = tmp_path / "test_output.svg" + signals = make_sine_signals() + stackplot_svg.save_svg(str(filepath), signals, sample_frequency=256.0, seconds=3.0) + + assert filepath.exists() + content = filepath.read_text() + root = ET.fromstring(content) + assert root.tag == f"{{{stackplot_svg.SVG_NS}}}svg" + + +def test_save_svg_with_calibration(tmp_path): + """Visual test: save SVG with calibration signal on last channel.""" + filepath = tmp_path / "test_calibration.svg" + num_samples = 800 + signals = np.random.randn(5, num_samples) * 30.0 + signals[-1, :] = make_calibration_signal(num_samples, levels=[0, 100, -100, 100, -100]) + + labels = ["Ch1", "Ch2", "Ch3", "Ch4", "Cal 100\u00b5V"] + stackplot_svg.save_svg( + str(filepath), + signals, + sample_frequency=256.0, + seconds=num_samples / 256.0, + ylabels=labels, + ) + assert filepath.exists() + + +def test_stackplot_svg_polyline_count(): + """Each channel should have exactly one polyline.""" + num_ch = 8 + signals = np.random.randn(num_ch, 400) + svg_str = stackplot_svg.stackplot_svg(signals, sample_frequency=256.0) + + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + polylines = root.findall(".//svg:polyline", ns) + assert len(polylines) == num_ch + + +def test_stackplot_svg_topdown_false(): + """With topdown=False, channel order should differ.""" + labels = ["A", "B", "C"] + signals = np.random.randn(3, 200) + + svg_td = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, ylabels=labels, topdown=True + ) + svg_bu = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, ylabels=labels, topdown=False + ) + + # both should be valid + ET.fromstring(svg_td) + ET.fromstring(svg_bu) + + # the label ordering in the SVG text should differ + # In topdown, "A" label appears first (at top); in bottom-up, "C" appears first + idx_a_td = svg_td.index(">A<") + idx_c_td = svg_td.index(">C<") + idx_a_bu = svg_bu.index(">A<") + idx_c_bu = svg_bu.index(">C<") + # topdown=True reverses order so C is drawn first (at top), A last (at bottom) + # topdown=False keeps natural order so A is drawn first + assert idx_c_td < idx_a_td + assert idx_a_bu < idx_c_bu From 4c67645cc418e6ccd23291f68f97a8b5315dbe7e Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Tue, 24 Feb 2026 19:27:21 -0800 Subject: [PATCH 02/17] progress on svg --- plans/svg-eeg-backend.md | 140 +++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 2 files changed, 141 insertions(+) create mode 100644 plans/svg-eeg-backend.md diff --git a/plans/svg-eeg-backend.md b/plans/svg-eeg-backend.md new file mode 100644 index 0000000..ffa0ef3 --- /dev/null +++ b/plans/svg-eeg-backend.md @@ -0,0 +1,140 @@ + + +# Plan: SVG EEG Visualization Backend + +## Context + +eegvis currently renders EEG traces via matplotlib (static) and bokeh/panel (interactive). We want a pure SVG backend that takes EEG data through the standard pipeline (downsample, montage, filter, plot) and outputs a standalone SVG file. This avoids the matplotlib dependency for static output and produces clean, lightweight vector graphics suitable for reports, web embedding, and archival. + +The StratusEEG commercial viewer (see `docs/stratus.md`) confirms SVG is viable for EEG display — they use `` per channel, `` for grid/axes, and `` for labels, all inside a `viewBox`-scaled SVG. + +## Approach + +Create a new module `eegvis/stackplot_svg.py` that mirrors the data flow of `stacklineplot.py` but outputs SVG directly using Python's `xml.etree.ElementTree` (no external dependencies). + +### Pipeline Steps (matching the user's 7-step description) + +``` +raw_signals (N channels, T samples) + 1. [optional] downsample + 2. montage matrix multiply → derived signals (M channels, T samples) + 3. generate montage labels + 4. apply bandpass filter (via eegml_signal.filters) + 5. compute vertical offsets, apply gain, flip polarity + 6. render as SVG polylines with labels, scale bars, time axis + 7. write .svg file +``` + +Steps 1-4 are handled by existing code (montageview.py, eegml_signal). The new module handles steps 5-7. + +### SVG Structure + +```xml + + + + + + + + ... + + + + + + Fp1-F7 + + + ... + + + + + + 100 µV + + + + + 0s + 1s + ... + + +``` + +### Key Design Decisions + +1. **No external dependencies** — use `xml.etree.ElementTree` for SVG generation. SVG is just XML. + +2. **Coordinate system** — SVG y-axis points down, which naturally matches the clinical "negative is up" convention when we negate the signal. Define a viewBox in mm or abstract units matching the desired output dimensions. + +3. **One polyline per channel** — following StratusEEG pattern. Convert each channel's (time, amplitude) data into a space-separated `points` attribute string. + +4. **Channel spacing** — reuse the same vertical offset logic from `stacklineplot.py`: + - Auto mode: `dr = 0.7 * (dmax - dmin)`, offset `i * dr` + - Sensitivity mode: `perchan_uV = (sensitivity * height_mm) / num_channels` + +5. **Downsampling for SVG size** — optional decimation to limit points per channel (e.g., cap at ~2000 points per channel for a 10s window at 256 Hz is already manageable, but 5kHz data needs decimation). + +6. **Labels** — channel labels as `` elements positioned at each channel's y-offset, left of the trace area. Time labels along the bottom. + +### Public API + +```python +def stackplot_svg( + signals, # (num_channels, num_samples) numpy array + sample_frequency, # Hz + ylabels=None, # channel names + seconds=None, # duration (derived from signals + fs if not given) + start_time=0.0, # time offset for labels + yscale=1.0, # gain multiplier (scalar or per-channel array) + sensitivity=None, # µV/mm (overrides auto-scaling) + width_mm=300, # SVG width in mm + height_mm=200, # SVG height in mm + topdown=True, # first channel at top +) -> str: + """Return SVG string of stacked EEG traces.""" + +def save_svg( + filepath, + signals, + sample_frequency, + **kwargs, # same as stackplot_svg +): + """Write SVG file to disk.""" + +def show_montage_svg( + signals, # raw signals (N channels, T samples) + montage, # MontageView instance + sample_frequency, + **kwargs, +) -> str: + """Apply montage derivation, then render as SVG.""" +``` + +### Files to Create/Modify + +| File | Action | Description | +|------|--------|-------------| +| `eegvis/stackplot_svg.py` | **Create** | New SVG backend module | +| `tests/test_stackplot_svg.py` | **Create** | Tests for SVG output | +| `eegvis/__init__.py` | No change needed | Import on demand | + +### Implementation Order + +1. **Core SVG rendering** — `stackplot_svg()` that takes pre-processed signals and outputs SVG string with traces, labels, and time axis +2. **Scale bars** — `add_vertical_scalebar()` helper +3. **Time grid** — vertical grid lines at 1s intervals +4. **`show_montage_svg()`** — convenience wrapper applying montage + rendering +5. **`save_svg()`** — file output wrapper +6. **Tests** — generate SVG from synthetic data, verify structure, visual spot-check + +### Verification + +- Generate SVG from synthetic sine wave data (multiple channels) +- Open in browser to visually verify layout +- Test with montage derivation (DoubleBananaMontageView) +- Compare channel spacing and scale bar against matplotlib version +- Validate SVG structure with `xml.etree.ElementTree.fromstring()` diff --git a/pyproject.toml b/pyproject.toml index 8391a36..422f80c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ classifiers = [ ] dynamic = ["version", "description"] # this means will be extracted from module docstring +# set requires python dependencies = [ "numpy", From 43bca117127576191742c05a52f3d6208f3c48a2 Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Wed, 25 Feb 2026 10:18:30 -0800 Subject: [PATCH 03/17] progress on initial svg output --- CLAUDE.md | 65 ++++++++++ eegvis/stackplot_svg.py | 214 ++++++++++++++++++++++++++++++--- plans/svg-eeg-backend.md | 190 ++++++++++++----------------- scripts/download_sample_edf.py | 32 +++++ tests/test_stackplot_svg.py | 186 ++++++++++++++++++++++++++++ 5 files changed, 561 insertions(+), 126 deletions(-) create mode 100644 CLAUDE.md create mode 100644 scripts/download_sample_edf.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f5b73a9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +eegvis is a Python library for visualizing EEG (electroencephalogram) data with +multiple backends: matplotlib (static), SVG, bokeh (interactive), and panel (dashboards). It targets clinical EEG workflows with montage-based channel derivations. + +## Build & Development + +```bash +# Install in development mode (uses flit backend) +pip install -e . + +# With optional EDF file support +pip install -e ".[eeghdf]" +pip install -e ".[pyedflib]" # pyedflib must be <=0.1.22 (sample_frequency bug in later versions) +``` + +## Testing + +```bash +# Run tests with pytest +pytest tests/ + +# Run via tox (multiple Python/matplotlib versions) +tox +tox -e py310 +tox -e py37-mpl3.2 # tests against matplotlib 3.2 (Colab compatibility) +``` + +## Architecture + +### Visualization Backends (three parallel implementations) + +- **`stacklineplot.py`** — Matplotlib backend using LineCollection with AffineDeltaTransform (backported from mpl 3.3 for 3.2 compatibility). Static/publication output. +- **`stackplot_bokeh.py`** — Bokeh backend with ipywidgets integration and `push_notebook()` for interactive Jupyter use. +- **`eegpanel.py`** / **`eegbokeh.py`** — Experimental Panel and pure-Bokeh browser implementations. + +### Montage System + +- **`montageview.py`** — Defines clinical EEG montages (Double Banana, TCP, Laplacian, etc.) as linear transformation matrices using xarray DataArrays over OrderedDicts of channels. +- **`montage_derivations_edf_simplified.py`** — Montage matrices for EDF-specific channel naming conventions ("EEG FP1", "EEG F3" style). + +### Data Abstraction + +- **`nb_eegview.py`** contains `MinimalEEGRecord` — the standard data container wrapping `signals` (channels × samples numpy array) + `sample_frequency`, with optional electrode labels, montages, and datetime metadata. + +### Signal Processing + +- Filtering uses external `eegml_signal.filters` package (notch, low-pass, high-pass). +- Known issue: firwin filters can produce ringing artifacts. + +### Support Modules + +- **`mpl_helpers.py`** — Matplotlib coordinate transforms and canvas utilities. +- **`utils/laplacian.py`** — Laplacian montage implementation with XML configuration. + +## Key Conventions + +- Signal data is stored as numpy arrays shaped (channels × samples). +- Montage derivations are expressed as xarray matrix operations over ordered channel dictionaries. +- Python 3.7+ required (f-strings). Some files retain `__future__` imports for historical compatibility. +- matplotlib >=3.2 is supported via backported `AffineDeltaTransform`. diff --git a/eegvis/stackplot_svg.py b/eegvis/stackplot_svg.py index 239ed1c..4829285 100644 --- a/eegvis/stackplot_svg.py +++ b/eegvis/stackplot_svg.py @@ -20,11 +20,102 @@ DEFAULT_TRACE_COLOR = "black" DEFAULT_TRACE_WIDTH = "0.5" DEFAULT_FONT_FAMILY = "sans-serif" -DEFAULT_FONT_SIZE = 10 # in SVG user units (roughly px) +DEFAULT_FONT_SIZE = 3.5 # in SVG user units (mm in viewBox coordinates) DEFAULT_GRID_COLOR = "#cccccc" DEFAULT_GRID_WIDTH = "0.3" +def downsample(signals, sample_frequency, target_frequency): + """Downsample signals to approximately target_frequency using scipy.signal.decimate. + + Applies an anti-aliasing filter before decimation. If sample_frequency + is already at or below target_frequency, returns signals unchanged. + + Args: + signals: (num_channels, num_samples) numpy array + sample_frequency: original sampling rate in Hz + target_frequency: desired output sampling rate in Hz + + Returns: + (downsampled_signals, new_sample_frequency) tuple + """ + if sample_frequency <= target_frequency: + return signals, sample_frequency + + from scipy.signal import decimate + + factor = int(sample_frequency / target_frequency) + if factor <= 1: + return signals, sample_frequency + + downsampled = decimate(signals, factor, axis=1) + new_fs = sample_frequency / factor + return downsampled, new_fs + + +def bandpass_filter(signals, sample_frequency, low_freq=1.0, high_freq=70.0): + """Apply a zero-phase bandpass filter to signals using eegml_signal. + + Uses FIR highpass and lowpass filters (firwin-based) applied sequentially. + + Args: + signals: (num_channels, num_samples) numpy array + sample_frequency: sampling rate in Hz + low_freq: high-pass cutoff frequency in Hz. + Set to None to skip high-pass (lowpass only). + high_freq: low-pass cutoff frequency in Hz. + Set to None to skip low-pass (highpass only). + + Returns: + filtered signals with same shape as input + """ + import eegml_signal.filters as esfilters + + result = signals.copy() + num_samples = signals.shape[1] + # filtfilt needs 3*numtaps < num_samples for padding + max_taps = num_samples // 3 - 1 + + if low_freq is not None: + numtaps = min(max(int(2 * sample_frequency), 3), max_taps) + if numtaps % 2 == 0: + numtaps += 1 # highpass firwin needs odd numtaps + hp = esfilters.fir_highpass_firwin_ff(sample_frequency, low_freq, numtaps) + for ch in range(result.shape[0]): + result[ch] = hp(result[ch]) + + if high_freq is not None: + numtaps = min(max(int(sample_frequency / 4.0), 3), max_taps) + lp = esfilters.fir_lowpass_firwin_ff(sample_frequency, high_freq, numtaps) + for ch in range(result.shape[0]): + result[ch] = lp(result[ch]) + + return result + + +def notch_filter(signals, sample_frequency, notch_freq=60.0, Q=30.0): + """Apply a zero-phase notch (band-stop) filter to remove line noise. + + Uses eegml_signal's IIR notch filter. + + Args: + signals: (num_channels, num_samples) numpy array + sample_frequency: sampling rate in Hz + notch_freq: frequency to remove in Hz (default: 60.0 for US mains) + Q: quality factor controlling notch width (default: 30.0) + + Returns: + filtered signals with same shape as input + """ + import eegml_signal.filters as esfilters + + nf = esfilters.notch_filter_iir_ff(notch_freq, sample_frequency, Q) + result = signals.copy() + for ch in range(result.shape[0]): + result[ch] = nf(result[ch]) + return result + + def _format_points(t, y): """Convert time and amplitude arrays to SVG polyline points string. @@ -77,6 +168,7 @@ def stackplot_svg( show_scalebar=True, scalebar_height=None, scalebar_units="\u00b5V", + max_samples_per_channel=None, ): """Generate an SVG string of stacked EEG traces. @@ -100,6 +192,9 @@ def stackplot_svg( scalebar_height: height of scale bar in data units. If None, auto-computed as ~10% of channel spacing. scalebar_units: units label for scale bar (default: µV) + max_samples_per_channel: if set, downsample signals so each channel + has at most this many samples. Reduces SVG file size for + high sample rate data. Set to None to disable (default). Returns: SVG content as a string @@ -109,6 +204,12 @@ def stackplot_svg( if seconds is None: seconds = num_samples / sample_frequency + # optional downsampling to limit SVG size + if max_samples_per_channel is not None and num_samples > max_samples_per_channel: + target_fs = max_samples_per_channel / seconds + signals, sample_frequency = downsample(signals, sample_frequency, target_fs) + num_channels, num_samples = signals.shape + if ylabels is None: ylabels = [str(i) for i in range(num_channels)] @@ -117,12 +218,12 @@ def stackplot_svg( if linewidth is None: linewidth = DEFAULT_TRACE_WIDTH - # layout constants - label_margin = 60 # left margin for channel labels - top_margin = 15 - bottom_margin = 25 # space for time axis labels - right_margin = 20 - scalebar_margin = 50 if show_scalebar else 0 + # layout constants (in viewBox units = mm) + label_margin = 25 # left margin for channel labels + top_margin = 5 + bottom_margin = 8 # space for time axis labels + right_margin = 5 + scalebar_margin = 20 if show_scalebar else 0 plot_width = width_mm - label_margin - right_margin - scalebar_margin plot_height = height_mm - top_margin - bottom_margin @@ -223,7 +324,7 @@ def data_y_to_svg(y_data): "height": f"{plot_height:.2f}", "fill": "none", "stroke": "#999999", - "stroke-width": "0.5", + "stroke-width": "0.2", }) # channel traces and labels @@ -241,7 +342,7 @@ def data_y_to_svg(y_data): # channel label label_y = data_y_to_svg(offset) ET.SubElement(ch_g, "text", { - "x": f"{label_margin - 4:.2f}", + "x": f"{label_margin - 1.5:.2f}", "y": f"{label_y:.2f}", "class": "label", }).text = label_order[draw_idx] @@ -257,7 +358,7 @@ def data_y_to_svg(y_data): # time axis labels time_g = ET.SubElement(svg, "g", {"class": "timeaxis"}) - time_label_y = top_margin + plot_height + 5 + time_label_y = top_margin + plot_height + 1.5 if grid_interval is not None: label_times = grid_times @@ -279,7 +380,7 @@ def data_y_to_svg(y_data): scalebar_height = dr * 0.5 scalebar_height = float(f"{scalebar_height:.1g}") - sb_x = label_margin + plot_width + 15 + sb_x = label_margin + plot_width + 3 # center the scale bar vertically in the plot sb_center_data = (y_data_min + y_data_max) / 2.0 sb_top_data = sb_center_data - scalebar_height / 2.0 @@ -296,17 +397,17 @@ def data_y_to_svg(y_data): "x2": f"{sb_x:.2f}", "y2": f"{sb_bot_svg:.2f}", "stroke": "black", - "stroke-width": "1", + "stroke-width": "0.3", }) # top end cap - cap_w = 3 + cap_w = 1 ET.SubElement(sb_g, "line", { "x1": f"{sb_x - cap_w:.2f}", "y1": f"{sb_top_svg:.2f}", "x2": f"{sb_x + cap_w:.2f}", "y2": f"{sb_top_svg:.2f}", "stroke": "black", - "stroke-width": "1", + "stroke-width": "0.3", }) # bottom end cap ET.SubElement(sb_g, "line", { @@ -315,12 +416,12 @@ def data_y_to_svg(y_data): "x2": f"{sb_x + cap_w:.2f}", "y2": f"{sb_bot_svg:.2f}", "stroke": "black", - "stroke-width": "1", + "stroke-width": "0.3", }) # label sb_label_y = (sb_top_svg + sb_bot_svg) / 2.0 ET.SubElement(sb_g, "text", { - "x": f"{sb_x + cap_w + 3:.2f}", + "x": f"{sb_x + cap_w + 1:.2f}", "y": f"{sb_label_y:.2f}", "class": "scalebar-label", }).text = f"{scalebar_height:.4g}{scalebar_units}" @@ -379,3 +480,84 @@ def save_montage_svg(filepath, signals, montage, sample_frequency, **kwargs): svg_str = show_montage_svg(signals, montage, sample_frequency, **kwargs) with open(filepath, "w", encoding="utf-8") as f: f.write(svg_str) + + +def eeg_to_svg( + signals, + sample_frequency, + montage=None, + low_freq=1.0, + high_freq=70.0, + notch_freq=None, + target_frequency=None, + max_samples_per_channel=None, + **kwargs, +): + """End-to-end pipeline: raw EEG signals to SVG string. + + Pipeline steps: + 1. Downsample (optional, if target_frequency is set) + 2. Apply montage derivation (optional, if montage is provided) + 3. Bandpass filter (optional, if low_freq or high_freq is set) + 4. Notch filter (optional, if notch_freq is set) + 5. Render as SVG via stackplot_svg() + + Args: + signals: raw signals (num_channels, num_samples) numpy array + sample_frequency: original sampling rate in Hz + montage: a MontageView instance (optional). If provided, applies + montage.V.data matrix multiply and uses montage.montage_labels. + low_freq: high-pass cutoff in Hz (default: 1.0). Set to None to skip. + high_freq: low-pass cutoff in Hz (default: 70.0). Set to None to skip. + notch_freq: notch filter frequency in Hz (default: None). + Common values: 60.0 (US) or 50.0 (EU). + target_frequency: if set, downsample to this rate before processing. + max_samples_per_channel: if set, limit samples per channel in the + final SVG (applied during rendering, after filtering). + **kwargs: passed to stackplot_svg() (e.g. width_mm, height_mm, + sensitivity, yscale, topdown, grid_interval, etc.) + + Returns: + SVG content as a string + """ + # 1. downsample + if target_frequency is not None: + signals, sample_frequency = downsample(signals, sample_frequency, target_frequency) + + # 2. montage derivation + ylabels = kwargs.pop("ylabels", None) + if montage is not None: + signals = np.dot(montage.V.data, signals) + if ylabels is None: + ylabels = montage.montage_labels + + # 3. bandpass filter + if low_freq is not None or high_freq is not None: + signals = bandpass_filter(signals, sample_frequency, low_freq, high_freq) + + # 4. notch filter + if notch_freq is not None: + signals = notch_filter(signals, sample_frequency, notch_freq) + + # 5. render + return stackplot_svg( + signals, + sample_frequency, + ylabels=ylabels, + max_samples_per_channel=max_samples_per_channel, + **kwargs, + ) + + +def save_eeg_svg(filepath, signals, sample_frequency, **kwargs): + """End-to-end pipeline: raw EEG signals to SVG file. + + Args: + filepath: output file path + signals: raw signals (num_channels, num_samples) numpy array + sample_frequency: sampling rate in Hz + **kwargs: passed to eeg_to_svg() + """ + svg_str = eeg_to_svg(signals, sample_frequency, **kwargs) + with open(filepath, "w", encoding="utf-8") as f: + f.write(svg_str) diff --git a/plans/svg-eeg-backend.md b/plans/svg-eeg-backend.md index ffa0ef3..14e38e6 100644 --- a/plans/svg-eeg-backend.md +++ b/plans/svg-eeg-backend.md @@ -1,5 +1,3 @@ - - # Plan: SVG EEG Visualization Backend ## Context @@ -8,133 +6,105 @@ eegvis currently renders EEG traces via matplotlib (static) and bokeh/panel (int The StratusEEG commercial viewer (see `docs/stratus.md`) confirms SVG is viable for EEG display — they use `` per channel, `` for grid/axes, and `` for labels, all inside a `viewBox`-scaled SVG. -## Approach +## Status + +### Completed + +- **Core SVG rendering** (`stackplot_svg()`) — generates SVG string with polyline-per-channel traces, channel labels, time axis labels, vertical grid lines, plot border, and scale bar. Uses `xml.etree.ElementTree`, no matplotlib dependency. +- **Scale bar** — vertical scale bar with end caps and label, auto-sized or explicit height. +- **Time grid** — vertical grid lines at configurable intervals. +- **Montage wrappers** — `show_montage_svg()` and `save_montage_svg()` apply montage derivation then render. +- **File output** — `save_svg()` writes to disk. +- **Downsampling** — `downsample()` function using `scipy.signal.decimate` with anti-aliasing. Also available as `max_samples_per_channel` parameter on `stackplot_svg()` for automatic decimation. +- **Bandpass filtering** — `bandpass_filter()` using `eegml_signal.filters` FIR highpass + lowpass. Auto-limits numtaps to signal length. +- **Notch filtering** — `notch_filter()` using `eegml_signal.filters.notch_filter_iir_ff`. +- **End-to-end pipeline** — `eeg_to_svg()` and `save_eeg_svg()` chain all steps: downsample → montage → bandpass → notch → render SVG. +- **Tests** — 23 tests covering: SVG structure, channel labels, sensitivity mode, grid/scalebar toggle, time labels, file output, topdown ordering, downsampling, bandpass attenuation/preservation, notch filtering, pipeline with montage, pipeline with all options. +- **Layout tuning** — font size reduced to 3.5mm, margins scaled for mm-based viewBox coordinates. Still needs further refinement (see TODO). + +### TODO -Create a new module `eegvis/stackplot_svg.py` that mirrors the data flow of `stacklineplot.py` but outputs SVG directly using Python's `xml.etree.ElementTree` (no external dependencies). +- **Layout polish** — scale bar positioning and sizing still needs work; label spacing could be improved; overall proportions need tuning with real EEG data. +- **Per-channel gain** — support array-valued `yscale` for individual channel sensitivity control. +- **Annotations/events** — support for marking events or time regions on the SVG (colored rect overlays). +- **Color per channel** — allow different trace colors (e.g. to distinguish + left/right hemisphere). +- spacers between channels to define groups of channels +- **Horizontal time scale bar** — in addition to the vertical amplitude scale bar. +- **Testing with real EEG files** — verify with eeghdf/edfio or pyedflib data, not just synthetic signals. -### Pipeline Steps (matching the user's 7-step description) +## Architecture + +### Files + +| File | Status | Description | +|-------------------------------|---------|----------------------------| +| `eegvis/stackplot_svg.py` | Created | SVG backend module | +| `tests/test_stackplot_svg.py` | Created | 23 tests | + +### Public API + +```python +# Low-level: render pre-processed signals +stackplot_svg(signals, sample_frequency, ...) -> str +save_svg(filepath, signals, sample_frequency, **kwargs) + +# Montage convenience wrappers +show_montage_svg(signals, montage, sample_frequency, **kwargs) -> str +save_montage_svg(filepath, signals, montage, sample_frequency, **kwargs) + +# Processing utilities (can be used standalone) +downsample(signals, sample_frequency, target_frequency) -> (signals, new_fs) +bandpass_filter(signals, sample_frequency, low_freq=1.0, high_freq=70.0) +notch_filter(signals, sample_frequency, notch_freq=60.0, Q=30.0) + +# End-to-end pipeline: raw data → SVG +eeg_to_svg(signals, sample_frequency, + montage=None, low_freq=1.0, high_freq=70.0, + notch_freq=None, target_frequency=None, + max_samples_per_channel=None, **kwargs) -> str +save_eeg_svg(filepath, signals, sample_frequency, **kwargs) +``` + +### Pipeline Steps ``` raw_signals (N channels, T samples) - 1. [optional] downsample + 1. downsample (optional, via scipy.signal.decimate) 2. montage matrix multiply → derived signals (M channels, T samples) - 3. generate montage labels - 4. apply bandpass filter (via eegml_signal.filters) - 5. compute vertical offsets, apply gain, flip polarity + 3. generate montage labels (from montage.montage_labels) + 4. bandpass filter (via eegml_signal.filters FIR highpass + lowpass) + 5. notch filter (optional, via eegml_signal.filters IIR notch) 6. render as SVG polylines with labels, scale bars, time axis 7. write .svg file ``` -Steps 1-4 are handled by existing code (montageview.py, eegml_signal). The new module handles steps 5-7. - ### SVG Structure +This is the initial stab at the SVG structure. May want to factor this more to +descriminate better between data traces and annotations in the future. ```xml - - + - - - - - ... - - - - + + + + - Fp1-F7 - + Fp1-F7 + - ... - - - - - - 100 µV - - - - - 0s - 1s - ... + + ``` ### Key Design Decisions -1. **No external dependencies** — use `xml.etree.ElementTree` for SVG generation. SVG is just XML. - -2. **Coordinate system** — SVG y-axis points down, which naturally matches the clinical "negative is up" convention when we negate the signal. Define a viewBox in mm or abstract units matching the desired output dimensions. - -3. **One polyline per channel** — following StratusEEG pattern. Convert each channel's (time, amplitude) data into a space-separated `points` attribute string. - -4. **Channel spacing** — reuse the same vertical offset logic from `stacklineplot.py`: - - Auto mode: `dr = 0.7 * (dmax - dmin)`, offset `i * dr` - - Sensitivity mode: `perchan_uV = (sensitivity * height_mm) / num_channels` - -5. **Downsampling for SVG size** — optional decimation to limit points per channel (e.g., cap at ~2000 points per channel for a 10s window at 256 Hz is already manageable, but 5kHz data needs decimation). - -6. **Labels** — channel labels as `` elements positioned at each channel's y-offset, left of the trace area. Time labels along the bottom. - -### Public API - -```python -def stackplot_svg( - signals, # (num_channels, num_samples) numpy array - sample_frequency, # Hz - ylabels=None, # channel names - seconds=None, # duration (derived from signals + fs if not given) - start_time=0.0, # time offset for labels - yscale=1.0, # gain multiplier (scalar or per-channel array) - sensitivity=None, # µV/mm (overrides auto-scaling) - width_mm=300, # SVG width in mm - height_mm=200, # SVG height in mm - topdown=True, # first channel at top -) -> str: - """Return SVG string of stacked EEG traces.""" - -def save_svg( - filepath, - signals, - sample_frequency, - **kwargs, # same as stackplot_svg -): - """Write SVG file to disk.""" - -def show_montage_svg( - signals, # raw signals (N channels, T samples) - montage, # MontageView instance - sample_frequency, - **kwargs, -) -> str: - """Apply montage derivation, then render as SVG.""" -``` - -### Files to Create/Modify - -| File | Action | Description | -|------|--------|-------------| -| `eegvis/stackplot_svg.py` | **Create** | New SVG backend module | -| `tests/test_stackplot_svg.py` | **Create** | Tests for SVG output | -| `eegvis/__init__.py` | No change needed | Import on demand | - -### Implementation Order - -1. **Core SVG rendering** — `stackplot_svg()` that takes pre-processed signals and outputs SVG string with traces, labels, and time axis -2. **Scale bars** — `add_vertical_scalebar()` helper -3. **Time grid** — vertical grid lines at 1s intervals -4. **`show_montage_svg()`** — convenience wrapper applying montage + rendering -5. **`save_svg()`** — file output wrapper -6. **Tests** — generate SVG from synthetic data, verify structure, visual spot-check - -### Verification - -- Generate SVG from synthetic sine wave data (multiple channels) -- Open in browser to visually verify layout -- Test with montage derivation (DoubleBananaMontageView) -- Compare channel spacing and scale bar against matplotlib version -- Validate SVG structure with `xml.etree.ElementTree.fromstring()` +1. **No matplotlib dependency** — pure `xml.etree.ElementTree` SVG generation. +2. **Coordinate system** — viewBox in mm. SVG y-down naturally gives "negative is up" clinical convention. +3. **One polyline per channel** — following StratusEEG pattern. +4. **Channel spacing** — auto mode (0.7 × data range) or sensitivity mode (µV/mm). +5. **Filtering via eegml_signal** — uses existing FIR/IIR filter functions, with numtaps auto-limited to avoid filtfilt padding errors on short signals. +6. **Downsampling via scipy** — `scipy.signal.decimate` with anti-aliasing filter. diff --git a/scripts/download_sample_edf.py b/scripts/download_sample_edf.py new file mode 100644 index 0000000..4143953 --- /dev/null +++ b/scripts/download_sample_edf.py @@ -0,0 +1,32 @@ +"""Download a sample EDF file from PhysioNet for testing. + +Source: EEG Motor Movement/Imagery Dataset (eegmmidb) +https://physionet.org/content/eegmmidb/1.0.0/ + +Downloads subject S001, run 01 (eyes-open baseline recording). +64 channels, 160 Hz, ~61 seconds. +""" + +import os +import urllib.request + +URL = "https://physionet.org/files/eegmmidb/1.0.0/S001/S001R01.edf" +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") +OUTPUT_PATH = os.path.join(DATA_DIR, "S001R01.edf") + + +def main(): + os.makedirs(DATA_DIR, exist_ok=True) + + if os.path.exists(OUTPUT_PATH): + print(f"Already exists: {OUTPUT_PATH}") + return + + print(f"Downloading {URL}") + urllib.request.urlretrieve(URL, OUTPUT_PATH) + size_kb = os.path.getsize(OUTPUT_PATH) / 1024 + print(f"Saved to {OUTPUT_PATH} ({size_kb:.0f} KB)") + + +if __name__ == "__main__": + main() diff --git a/tests/test_stackplot_svg.py b/tests/test_stackplot_svg.py index 8e91b0e..9043536 100644 --- a/tests/test_stackplot_svg.py +++ b/tests/test_stackplot_svg.py @@ -173,3 +173,189 @@ def test_stackplot_svg_topdown_false(): # topdown=False keeps natural order so A is drawn first assert idx_c_td < idx_a_td assert idx_a_bu < idx_c_bu + + +def test_small_2channel_svg(): + """Generate a minimal 2-channel, 100-sample SVG for manual inspection. + + Output is written to test_small_2ch.svg in the current working directory. + """ + fs = 100.0 + n = 100 + t = np.arange(n) / fs + signals = np.zeros((2, n)) + signals[0, :] = 50.0 * np.sin(2 * np.pi * 3 * t) # 3 Hz sine + signals[1, :] = 30.0 * np.sin(2 * np.pi * 10 * t) # 10 Hz sine + + stackplot_svg.save_svg( + "test_small_2ch.svg", + signals, + sample_frequency=fs, + seconds=n / fs, + ylabels=["3 Hz (50uV)", "10 Hz (30uV)"], + width_mm=200, + height_mm=100, + ) + + +def test_downsample_reduces_samples(): + """downsample should reduce sample count by the decimation factor.""" + signals = np.random.randn(3, 2560) # 10s at 256 Hz + ds, new_fs = stackplot_svg.downsample(signals, 256.0, 64.0) + assert ds.shape[0] == 3 + assert ds.shape[1] == 2560 // 4 # factor of 4 + assert new_fs == 64.0 + + +def test_downsample_noop_when_already_low(): + """downsample should return data unchanged if fs <= target.""" + signals = np.random.randn(2, 100) + ds, new_fs = stackplot_svg.downsample(signals, 50.0, 256.0) + assert ds is signals # same object, not a copy + assert new_fs == 50.0 + + +def test_max_samples_per_channel_limits_polyline_points(): + """max_samples_per_channel should produce fewer points in SVG.""" + signals = np.random.randn(2, 5000) # high sample count + svg_full = stackplot_svg.stackplot_svg(signals, sample_frequency=1000.0, seconds=5.0) + svg_ds = stackplot_svg.stackplot_svg( + signals, sample_frequency=1000.0, seconds=5.0, max_samples_per_channel=500 + ) + # downsampled SVG should be substantially smaller + assert len(svg_ds) < len(svg_full) * 0.5 + + +def test_bandpass_filter_attenuates_out_of_band(): + """Bandpass 5-30 Hz should attenuate a 1 Hz and a 100 Hz signal.""" + fs = 512.0 + n = int(fs * 4) # 4 seconds for filter settling + t = np.arange(n) / fs + signals = np.zeros((2, n)) + signals[0, :] = np.sin(2 * np.pi * 1.0 * t) # 1 Hz — below band + signals[1, :] = np.sin(2 * np.pi * 100.0 * t) # 100 Hz — above band + + filtered = stackplot_svg.bandpass_filter(signals, fs, low_freq=5.0, high_freq=30.0) + + # middle portion (avoid edge effects) should be heavily attenuated + mid = slice(n // 4, 3 * n // 4) + assert np.std(filtered[0, mid]) < 0.1 # 1 Hz mostly removed + assert np.std(filtered[1, mid]) < 0.1 # 100 Hz mostly removed + + +def test_bandpass_filter_preserves_in_band(): + """Bandpass 1-70 Hz should preserve a 10 Hz signal.""" + fs = 256.0 + n = int(fs * 4) + t = np.arange(n) / fs + signals = np.zeros((1, n)) + signals[0, :] = np.sin(2 * np.pi * 10.0 * t) + + filtered = stackplot_svg.bandpass_filter(signals, fs, low_freq=1.0, high_freq=70.0) + + mid = slice(n // 4, 3 * n // 4) + # amplitude should be mostly preserved (within 10%) + assert np.std(filtered[0, mid]) > 0.6 + + +def test_notch_filter_removes_line_noise(): + """Notch at 60 Hz should attenuate 60 Hz while preserving 10 Hz.""" + fs = 512.0 + n = int(fs * 4) + t = np.arange(n) / fs + signals = np.zeros((1, n)) + signals[0, :] = np.sin(2 * np.pi * 10.0 * t) + np.sin(2 * np.pi * 60.0 * t) + + filtered = stackplot_svg.notch_filter(signals, fs, notch_freq=60.0) + + mid = slice(n // 4, 3 * n // 4) + # 60 Hz component should be gone; 10 Hz should remain + # original std is ~1.0 (two unit sines), filtered should be ~0.7 (one sine) + assert 0.5 < np.std(filtered[0, mid]) < 0.9 + + +def test_eeg_to_svg_basic(): + """eeg_to_svg should produce valid SVG with default bandpass.""" + fs = 256.0 + n = int(fs * 10) + signals = np.random.randn(4, n) * 50.0 + + svg_str = stackplot_svg.eeg_to_svg( + signals, fs, + ylabels=["Ch1", "Ch2", "Ch3", "Ch4"], + seconds=10.0, + ) + root = ET.fromstring(svg_str) + assert root.tag == f"{{{stackplot_svg.SVG_NS}}}svg" + ns = {"svg": stackplot_svg.SVG_NS} + assert len(root.findall(".//svg:g[@class='channel']", ns)) == 4 + + +def test_eeg_to_svg_with_montage(): + """eeg_to_svg with a montage should use montage labels.""" + from eegvis.montageview import DoubleBananaMontageView + + rec_labels = [ + "Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4", + "O1", "O2", "F7", "F8", "T3", "T4", "T5", "T6", + "Fz", "Cz", "Pz", + ] + montage = DoubleBananaMontageView(rec_labels) + fs = 256.0 + n = int(fs * 10) + signals = np.random.randn(len(rec_labels), n) * 50.0 + + svg_str = stackplot_svg.eeg_to_svg(signals, fs, montage=montage, seconds=10.0) + + # should contain montage-derived labels + assert "Fp1-F7" in svg_str + assert "F7-T3" in svg_str + + +def test_eeg_to_svg_no_filter(): + """eeg_to_svg with filters disabled should still produce valid SVG.""" + fs = 256.0 + n = int(fs * 5) + signals = np.random.randn(2, n) * 30.0 + + svg_str = stackplot_svg.eeg_to_svg( + signals, fs, low_freq=None, high_freq=None, seconds=5.0, + ) + root = ET.fromstring(svg_str) + assert root.tag == f"{{{stackplot_svg.SVG_NS}}}svg" + + +def test_eeg_to_svg_with_notch_and_downsample(): + """eeg_to_svg with all pipeline steps enabled.""" + fs = 1000.0 + n = int(fs * 10) + signals = np.random.randn(3, n) * 50.0 + + svg_str = stackplot_svg.eeg_to_svg( + signals, fs, + low_freq=1.0, high_freq=70.0, + notch_freq=60.0, + target_frequency=256.0, + max_samples_per_channel=1000, + seconds=10.0, + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + assert len(root.findall(".//svg:g[@class='channel']", ns)) == 3 + + +def test_save_eeg_svg(tmp_path): + """save_eeg_svg should write a valid SVG file through the full pipeline.""" + filepath = tmp_path / "pipeline_output.svg" + fs = 256.0 + n = int(fs * 5) + signals = np.random.randn(3, n) * 50.0 + + stackplot_svg.save_eeg_svg( + str(filepath), signals, fs, + low_freq=1.0, high_freq=70.0, + seconds=5.0, + ) + assert filepath.exists() + root = ET.fromstring(filepath.read_text()) + assert root.tag == f"{{{stackplot_svg.SVG_NS}}}svg" From f3a1c069709c8838f9fb46079dba61a5cc7d13ef Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Sat, 28 Feb 2026 10:04:26 -0800 Subject: [PATCH 04/17] update to uv.lock and plans-prompts --- plans-prompts/claude-prompt-svg-prompt.md | 14 + uv.lock | 1226 +++++++++++++++++++++ 2 files changed, 1240 insertions(+) create mode 100644 plans-prompts/claude-prompt-svg-prompt.md create mode 100644 uv.lock diff --git a/plans-prompts/claude-prompt-svg-prompt.md b/plans-prompts/claude-prompt-svg-prompt.md new file mode 100644 index 0000000..eb00328 --- /dev/null +++ b/plans-prompts/claude-prompt-svg-prompt.md @@ -0,0 +1,14 @@ +I would like to expand the library to include the ability to: generate SVG versions of EEG. + +Here is an overview of the EEG pipeline + +EEG raw data pipeline steps: +1. take orginal data +2. downsample data if necessary +3. select montage and transform original data into montage derivation of choice. This is often a linear operation. +4. generate new labels for channels +5. apply appropriate band-pass filter (often 1-70 Hz but this is selectable) +6. plot the montaged channels, usually with convention that "negative is up" with chosen per channel gain to scale the waveform appropriately +7. decorate the plot with appropriate scale bars and the time and channel labels + +Please propose how to do this for SVG. Let's start with a simple task. The output of the above pipeline would be an SVG file. \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..93935f9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1226 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "bleach" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, +] + +[[package]] +name = "bokeh" +version = "3.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "jinja2" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "pyyaml" }, + { name = "tornado", marker = "sys_platform != 'emscripten'" }, + { name = "xyzservices" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/7ee0c4dfd0255631b0624ce01be178704f91f763f02a1879368eb109befd/bokeh-3.8.2.tar.gz", hash = "sha256:8e7dcacc21d53905581b54328ad2705954f72f2997f99fc332c1de8da53aa3cc", size = 6529251, upload-time = "2026-01-06T00:20:06.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/a8/877f306720bc114c612579c5af36bcb359026b83d051226945499b306b1a/bokeh-3.8.2-py3-none-any.whl", hash = "sha256:5e2c0d84f75acb25d60efb9e4d2f434a791c4639b47d685534194c4e07bd0111", size = 7207131, upload-time = "2026-01-06T00:20:04.917Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/2e/f6cb9a8a13f5058f0a20fe09711a7b726232cd5a78c6a7c05b2ec726cff9/debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173", size = 2538066, upload-time = "2026-01-29T23:03:54.999Z" }, + { url = "https://files.pythonhosted.org/packages/c5/56/6ddca50b53624e1ca3ce1d1e49ff22db46c47ea5fb4c0cc5c9b90a616364/debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad", size = 4269425, upload-time = "2026-01-29T23:03:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d9/d64199c14a0d4c476df46c82470a3ce45c8d183a6796cfb5e66533b3663c/debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f", size = 5331407, upload-time = "2026-01-29T23:03:58.481Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d9/1f07395b54413432624d61524dfd98c1a7c7827d2abfdb8829ac92638205/debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be", size = 5372521, upload-time = "2026-01-29T23:03:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "eeghdf" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "future" }, + { name = "h5py" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/b3/4a270a0ac81cd343815ae8e7a0b7e0060ff04c3b032958dd0f05e92ff3b7/eeghdf-0.2.4.tar.gz", hash = "sha256:e9b3f91ed5dadb99ff6ab3ba48ba40cbb2146925069a3d450deef1089c5039a2", size = 52927080, upload-time = "2022-07-17T00:20:37.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/7b/bdf83f237d3b2795c5a894dcfbc1b3d6da7add82b3bdef81c1973022533c/eeghdf-0.2.4-py3-none-any.whl", hash = "sha256:b1e1c2c104eab9a080cd7bc05d6245c52b91a9aa3631e70127e3369563c249cb", size = 18832, upload-time = "2022-07-17T00:20:17.62Z" }, +] + +[[package]] +name = "eegml-signal" +version = "0.0.1" +source = { git = "https://github.com/eegml/eegml-signal.git?rev=master#daad205dd85f04a1d75ec248071b2f9b9f150f03" } + +[[package]] +name = "eegvis" +source = { editable = "." } +dependencies = [ + { name = "bokeh" }, + { name = "eegml-signal" }, + { name = "ipywidgets" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "panel" }, + { name = "scipy" }, + { name = "xarray" }, +] + +[package.optional-dependencies] +eeghdf = [ + { name = "eeghdf" }, +] +pyedflib = [ + { name = "pyedflib" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ipykernel" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "bokeh", specifier = ">=0.12.16" }, + { name = "eeghdf", marker = "extra == 'eeghdf'" }, + { name = "eegml-signal", git = "https://github.com/eegml/eegml-signal.git?rev=master" }, + { name = "ipywidgets", specifier = ">=7.0" }, + { name = "matplotlib", specifier = ">=3.2" }, + { name = "numpy" }, + { name = "panel" }, + { name = "pyedflib", marker = "extra == 'pyedflib'", specifier = "<=0.1.22" }, + { name = "scipy" }, + { name = "xarray" }, +] +provides-extras = ["eeghdf", "pyedflib"] + +[package.metadata.requires-dev] +dev = [ + { name = "ipykernel", specifier = ">=7.2.0" }, + { name = "pytest", specifier = ">=9.0.2" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fonttools" +version = "4.61.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/ca/cf17b88a8df95691275a3d77dc0a5ad9907f328ae53acbe6795da1b2f5ed/fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69", size = 3565756, upload-time = "2025-12-12T17:31:24.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/8f/4e7bf82c0cbb738d3c2206c920ca34ca74ef9dabde779030145d28665104/fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd", size = 2846094, upload-time = "2025-12-12T17:30:43.511Z" }, + { url = "https://files.pythonhosted.org/packages/71/09/d44e45d0a4f3a651f23a1e9d42de43bc643cce2971b19e784cc67d823676/fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e", size = 2396589, upload-time = "2025-12-12T17:30:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/89/18/58c64cafcf8eb677a99ef593121f719e6dcbdb7d1c594ae5a10d4997ca8a/fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c", size = 4877892, upload-time = "2025-12-12T17:30:47.709Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ec/9e6b38c7ba1e09eb51db849d5450f4c05b7e78481f662c3b79dbde6f3d04/fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75", size = 4972884, upload-time = "2025-12-12T17:30:49.656Z" }, + { url = "https://files.pythonhosted.org/packages/5e/87/b5339da8e0256734ba0dbbf5b6cdebb1dd79b01dc8c270989b7bcd465541/fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063", size = 4924405, upload-time = "2025-12-12T17:30:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/0b/47/e3409f1e1e69c073a3a6fd8cb886eb18c0bae0ee13db2c8d5e7f8495e8b7/fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2", size = 5035553, upload-time = "2025-12-12T17:30:54.823Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b6/1f6600161b1073a984294c6c031e1a56ebf95b6164249eecf30012bb2e38/fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c", size = 2271915, upload-time = "2025-12-12T17:30:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/52/7b/91e7b01e37cc8eb0e1f770d08305b3655e4f002fc160fb82b3390eabacf5/fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c", size = 2323487, upload-time = "2025-12-12T17:30:59.804Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/908ad78e46c61c3e3ed70c3b58ff82ab48437faf84ec84f109592cabbd9f/fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa", size = 2929571, upload-time = "2025-12-12T17:31:02.574Z" }, + { url = "https://files.pythonhosted.org/packages/bd/41/975804132c6dea64cdbfbaa59f3518a21c137a10cccf962805b301ac6ab2/fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91", size = 2435317, upload-time = "2025-12-12T17:31:04.974Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5a/aef2a0a8daf1ebaae4cfd83f84186d4a72ee08fd6a8451289fcd03ffa8a4/fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19", size = 4882124, upload-time = "2025-12-12T17:31:07.456Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/d6db3485b645b81cea538c9d1c9219d5805f0877fda18777add4671c5240/fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba", size = 5100391, upload-time = "2025-12-12T17:31:09.732Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d6/675ba631454043c75fcf76f0ca5463eac8eb0666ea1d7badae5fea001155/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7", size = 4978800, upload-time = "2025-12-12T17:31:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/7f/33/d3ec753d547a8d2bdaedd390d4a814e8d5b45a093d558f025c6b990b554c/fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118", size = 5006426, upload-time = "2025-12-12T17:31:13.764Z" }, + { url = "https://files.pythonhosted.org/packages/b4/40/cc11f378b561a67bea850ab50063366a0d1dd3f6d0a30ce0f874b0ad5664/fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5", size = 2335377, upload-time = "2025-12-12T17:31:16.49Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ff/c9a2b66b39f8628531ea58b320d66d951267c98c6a38684daa8f50fb02f8/fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b", size = 2400613, upload-time = "2025-12-12T17:31:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4e/ce75a57ff3aebf6fc1f4e9d508b8e5810618a33d900ad6c19eb30b290b97/fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371", size = 1148996, upload-time = "2025-12-12T17:31:21.03Z" }, +] + +[[package]] +name = "future" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, +] + +[[package]] +name = "h5py" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/6a/0d79de0b025aa85dc8864de8e97659c94cf3d23148394a954dc5ca52f8c8/h5py-3.15.1.tar.gz", hash = "sha256:c86e3ed45c4473564de55aa83b6fc9e5ead86578773dfbd93047380042e26b69", size = 426236, upload-time = "2025-10-16T10:35:27.404Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/2c/926eba1514e4d2e47d0e9eb16c784e717d8b066398ccfca9b283917b1bfb/h5py-3.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f4fb0567eb8517c3ecd6b3c02c4f4e9da220c8932604960fd04e24ee1254763", size = 3380368, upload-time = "2025-10-16T10:35:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/65/4b/d715ed454d3baa5f6ae1d30b7eca4c7a1c1084f6a2edead9e801a1541d62/h5py-3.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:954e480433e82d3872503104f9b285d369048c3a788b2b1a00e53d1c47c98dd2", size = 2833793, upload-time = "2025-10-16T10:35:05.623Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d4/ef386c28e4579314610a8bffebbee3b69295b0237bc967340b7c653c6c10/h5py-3.15.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd125c131889ebbef0849f4a0e29cf363b48aba42f228d08b4079913b576bb3a", size = 4903199, upload-time = "2025-10-16T10:35:08.972Z" }, + { url = "https://files.pythonhosted.org/packages/33/5d/65c619e195e0b5e54ea5a95c1bb600c8ff8715e0d09676e4cce56d89f492/h5py-3.15.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28a20e1a4082a479b3d7db2169f3a5034af010b90842e75ebbf2e9e49eb4183e", size = 5097224, upload-time = "2025-10-16T10:35:12.808Z" }, + { url = "https://files.pythonhosted.org/packages/30/30/5273218400bf2da01609e1292f562c94b461fcb73c7a9e27fdadd43abc0a/h5py-3.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8df5267f545b4946df8ca0d93d23382191018e4cda2deda4c2cedf9a010e13", size = 4551207, upload-time = "2025-10-16T10:35:16.24Z" }, + { url = "https://files.pythonhosted.org/packages/d3/39/a7ef948ddf4d1c556b0b2b9559534777bccc318543b3f5a1efdf6b556c9c/h5py-3.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99d374a21f7321a4c6ab327c4ab23bd925ad69821aeb53a1e75dd809d19f67fa", size = 5025426, upload-time = "2025-10-16T10:35:19.831Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d8/7368679b8df6925b8415f9dcc9ab1dab01ddc384d2b2c24aac9191bd9ceb/h5py-3.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:9c73d1d7cdb97d5b17ae385153472ce118bed607e43be11e9a9deefaa54e0734", size = 2865704, upload-time = "2025-10-16T10:35:22.658Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/4a806f85d62c20157e62e58e03b27513dc9c55499768530acc4f4c5ce4be/h5py-3.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:a6d8c5a05a76aca9a494b4c53ce8a9c29023b7f64f625c6ce1841e92a362ccdf", size = 2465544, upload-time = "2025-10-16T10:35:25.695Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/8d/b68b728e2d06b9e0051019640a40a9eb7a88fcd82c2e1b5ce70bef5ff044/ipykernel-7.2.0.tar.gz", hash = "sha256:18ed160b6dee2cbb16e5f3575858bc19d8f1fe6046a9a680c708494ce31d909e", size = 176046, upload-time = "2026-02-06T16:43:27.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b9/e73d5d9f405cba7706c539aa8b311b49d4c2f3d698d9c12f815231169c71/ipykernel-7.2.0-py3-none-any.whl", hash = "sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661", size = 118788, upload-time = "2026-02-06T16:43:25.149Z" }, +] + +[[package]] +name = "ipython" +version = "9.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/60/2111715ea11f39b1535bed6024b7dec7918b71e5e5d30855a5b503056b50/ipython-9.10.0.tar.gz", hash = "sha256:cd9e656be97618a0676d058134cd44e6dc7012c0e5cb36a9ce96a8c904adaf77", size = 4426526, upload-time = "2026-02-02T10:00:33.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/aa/898dec789a05731cd5a9f50605b7b44a72bd198fd0d4528e11fc610177cc/ipython-9.10.0-py3-none-any.whl", hash = "sha256:c6ab68cc23bba8c7e18e9b932797014cc61ea7fd6f19de180ab9ba73e65ee58d", size = 622774, upload-time = "2026-02-02T10:00:31.503Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, + { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, + { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, + { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, + { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, + { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, + { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, + { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, + { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, + { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, + { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "narwhals" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/59/81d0f4cad21484083466f278e6b392addd9f4205b48d45b5c8771670ebf8/narwhals-2.17.0.tar.gz", hash = "sha256:ebd5bc95bcfa2f8e89a8ac09e2765a63055162837208e67b42d6eeb6651d5e67", size = 620306, upload-time = "2026-02-23T09:44:34.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/27/20770bd6bf8fbe1e16f848ba21da9df061f38d2e6483952c29d2bb5d1d8b/narwhals-2.17.0-py3-none-any.whl", hash = "sha256:2ac5307b7c2b275a7d66eeda906b8605e3d7a760951e188dcfff86e8ebe083dd", size = 444897, upload-time = "2026-02-23T09:44:32.006Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/8b/4bb774a998b97e6c2fd62a9e6cfdaae133b636fd1c468f92afb4ae9a447a/pandas-3.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:99d0f92ed92d3083d140bf6b97774f9f13863924cf3f52a70711f4e7588f9d0a", size = 10322465, upload-time = "2026-02-17T22:19:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/72/3a/5b39b51c64159f470f1ca3b1c2a87da290657ca022f7cd11442606f607d1/pandas-3.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b66857e983208654294bb6477b8a63dee26b37bdd0eb34d010556e91261784f", size = 9910632, upload-time = "2026-02-17T22:19:39.001Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f7/b449ffb3f68c11da12fc06fbf6d2fa3a41c41e17d0284d23a79e1c13a7e4/pandas-3.0.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56cf59638bf24dc9bdf2154c81e248b3289f9a09a6d04e63608c159022352749", size = 10440535, upload-time = "2026-02-17T22:19:41.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/77/6ea82043db22cb0f2bbfe7198da3544000ddaadb12d26be36e19b03a2dc5/pandas-3.0.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a9f55e0f46951874b863d1f3906dcb57df2d9be5c5847ba4dfb55b2c815249", size = 10893940, upload-time = "2026-02-17T22:19:43.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/30/f1b502a72468c89412c1b882a08f6eed8a4ee9dc033f35f65d0663df6081/pandas-3.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1849f0bba9c8a2fb0f691d492b834cc8dadf617e29015c66e989448d58d011ee", size = 11442711, upload-time = "2026-02-17T22:19:46.074Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f0/ebb6ddd8fc049e98cabac5c2924d14d1dda26a20adb70d41ea2e428d3ec4/pandas-3.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3d288439e11b5325b02ae6e9cc83e6805a62c40c5a6220bea9beb899c073b1c", size = 11963918, upload-time = "2026-02-17T22:19:48.838Z" }, + { url = "https://files.pythonhosted.org/packages/09/f8/8ce132104074f977f907442790eaae24e27bce3b3b454e82faa3237ff098/pandas-3.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:93325b0fe372d192965f4cca88d97667f49557398bbf94abdda3bf1b591dbe66", size = 9862099, upload-time = "2026-02-17T22:19:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/6af9aac41ef2456b768ef0ae60acf8abcebb450a52043d030a65b4b7c9bd/pandas-3.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:97ca08674e3287c7148f4858b01136f8bdfe7202ad25ad04fec602dd1d29d132", size = 9185333, upload-time = "2026-02-17T22:19:53.266Z" }, + { url = "https://files.pythonhosted.org/packages/66/fc/848bb6710bc6061cb0c5badd65b92ff75c81302e0e31e496d00029fe4953/pandas-3.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:58eeb1b2e0fb322befcf2bbc9ba0af41e616abadb3d3414a6bc7167f6cbfce32", size = 10772664, upload-time = "2026-02-17T22:19:55.806Z" }, + { url = "https://files.pythonhosted.org/packages/69/5c/866a9bbd0f79263b4b0db6ec1a341be13a1473323f05c122388e0f15b21d/pandas-3.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cd9af1276b5ca9e298bd79a26bda32fa9cc87ed095b2a9a60978d2ca058eaf87", size = 10421286, upload-time = "2026-02-17T22:19:58.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/a4/2058fb84fb1cfbfb2d4a6d485e1940bb4ad5716e539d779852494479c580/pandas-3.0.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f87a04984d6b63788327cd9f79dda62b7f9043909d2440ceccf709249ca988", size = 10342050, upload-time = "2026-02-17T22:20:01.376Z" }, + { url = "https://files.pythonhosted.org/packages/22/1b/674e89996cc4be74db3c4eb09240c4bb549865c9c3f5d9b086ff8fcfbf00/pandas-3.0.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85fe4c4df62e1e20f9db6ebfb88c844b092c22cd5324bdcf94bfa2fc1b391221", size = 10740055, upload-time = "2026-02-17T22:20:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f8/e954b750764298c22fa4614376531fe63c521ef517e7059a51f062b87dca/pandas-3.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:331ca75a2f8672c365ae25c0b29e46f5ac0c6551fdace8eec4cd65e4fac271ff", size = 11357632, upload-time = "2026-02-17T22:20:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/6d/02/c6e04b694ffd68568297abd03588b6d30295265176a5c01b7459d3bc35a3/pandas-3.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15860b1fdb1973fffade772fdb931ccf9b2f400a3f5665aef94a00445d7d8dd5", size = 11810974, upload-time = "2026-02-17T22:20:08.946Z" }, + { url = "https://files.pythonhosted.org/packages/89/41/d7dfb63d2407f12055215070c42fc6ac41b66e90a2946cdc5e759058398b/pandas-3.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:44f1364411d5670efa692b146c748f4ed013df91ee91e9bec5677fb1fd58b937", size = 10884622, upload-time = "2026-02-17T22:20:11.711Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" }, +] + +[[package]] +name = "panel" +version = "1.8.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bleach" }, + { name = "bokeh" }, + { name = "linkify-it-py" }, + { name = "markdown" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "param" }, + { name = "pyviz-comms" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/39/e34d76638e8312d79855dd8f3bbce2b07484031bac4b791503ac47ce3daf/panel-1.8.7.tar.gz", hash = "sha256:76c9822e899ee08b945e562c3ae8e028e508019fd61ba0129abbf24d02ea031d", size = 32135803, upload-time = "2026-01-28T16:52:52.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/75/689e2ebb7fca5c7c67ce2e2a91538b66b75a3e27a7496aa9ed96c349b2a6/panel-1.8.7-py3-none-any.whl", hash = "sha256:6cc60a8b5497628a896b935706701ff9b640ed001f6a48d0bd67163938b546da", size = 30223006, upload-time = "2026-01-28T16:52:49.518Z" }, +] + +[[package]] +name = "param" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/bc/6f3e6a2cbde006f9a5a4591fd87ec71ee8007252e93bc23b803d1cfa043a/param-2.3.2.tar.gz", hash = "sha256:ec70669bda9a3c13491098e7f5b640f60022b58b2f5bc7997099d54ea237d1de", size = 201733, upload-time = "2026-02-06T17:41:48.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/b6/f8c7e1f5f716e16070cf35f90c24f95f397376bb810e65000b6bc55950cc/param-2.3.2-py3-none-any.whl", hash = "sha256:147717b21cf2d8add08edb135f678c5fda08a701dc69e0897d75812e4c2af365", size = 139763, upload-time = "2026-02-06T17:41:46.792Z" }, +] + +[[package]] +name = "parso" +version = "0.8.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pyedflib" +version = "0.1.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/32/d70313f001a4c1494c5761b37836054239de428539dd7c76c20f108a610a/pyEDFlib-0.1.22.tar.gz", hash = "sha256:a78dc945e631f33a490d790aed5f70a7f23ae8bd54d1ef19de9ddd0eee796d94", size = 1256747, upload-time = "2021-04-16T14:42:59.654Z" } + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyviz-comms" +version = "3.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "param" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/ee/2b5367b911bab506662abffe6f342101a9b3edacee91ff9afe62db5fe9a7/pyviz_comms-3.0.6.tar.gz", hash = "sha256:73d66b620390d97959b2c4d8a2c0778d41fe20581be4717f01e46b8fae8c5695", size = 197772, upload-time = "2025-06-20T16:50:30.97Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/5a/f8c0868199bbb231a02616286ce8a4ccb85f5387b9215510297dcfedd214/pyviz_comms-3.0.6-py3-none-any.whl", hash = "sha256:4eba6238cd4a7f4add2d11879ce55411785b7d38a7c5dba42c7a0826ca53e6c2", size = 84275, upload-time = "2025-06-20T16:50:28.826Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, + { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, +] + +[[package]] +name = "xarray" +version = "2026.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/03/e3353b72e518574b32993989d8f696277bf878e9d508c7dd22e86c0dab5b/xarray-2026.2.0.tar.gz", hash = "sha256:978b6acb018770554f8fd964af4eb02f9bcc165d4085dbb7326190d92aa74bcf", size = 3111388, upload-time = "2026-02-13T22:20:50.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/92/545eb2ca17fc0e05456728d7e4378bfee48d66433ae3b7e71948e46826fb/xarray-2026.2.0-py3-none-any.whl", hash = "sha256:e927d7d716ea71dea78a13417970850a640447d8dd2ceeb65c5687f6373837c9", size = 1405358, upload-time = "2026-02-13T22:20:47.847Z" }, +] + +[[package]] +name = "xyzservices" +version = "2025.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/022795fc1201e7c29e742a509913badb53ce0b38f64b6db859e2f6339da9/xyzservices-2025.11.0.tar.gz", hash = "sha256:2fc72b49502b25023fd71e8f532fb4beddbbf0aa124d90ea25dba44f545e17ce", size = 1135703, upload-time = "2025-11-22T11:31:51.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/5c/2c189d18d495dd0fa3f27ccc60762bbc787eed95b9b0147266e72bb76585/xyzservices-2025.11.0-py3-none-any.whl", hash = "sha256:de66a7599a8d6dad63980b77defd1d8f5a5a9cb5fc8774ea1c6e89ca7c2a3d2f", size = 93916, upload-time = "2025-11-22T11:31:50.525Z" }, +] From e814d0d1c0772be3724614e7bd678f90d2b0d9aa Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Fri, 27 Mar 2026 19:52:12 -0700 Subject: [PATCH 05/17] refactor SVG structure for interactive viewer support Separate channel baseline offset (translate) from signal gain (scale) in SVG output to enable client-side gain adjustment without server round-trips. Add data-* attributes for JS discoverability, per-channel yscale array support, vector-effect non-scaling-stroke, and empty annotations layer. Co-Authored-By: Claude Opus 4.6 (1M context) --- eegvis/stackplot_svg.py | 45 ++++++-- plans/eeg-clinical-viewer.md | 192 +++++++++++++++++++++++++++++++++++ tests/test_stackplot_svg.py | 133 ++++++++++++++++++++++++ 3 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 plans/eeg-clinical-viewer.md diff --git a/eegvis/stackplot_svg.py b/eegvis/stackplot_svg.py index 4829285..2d105fa 100644 --- a/eegvis/stackplot_svg.py +++ b/eegvis/stackplot_svg.py @@ -218,6 +218,14 @@ def stackplot_svg( if linewidth is None: linewidth = DEFAULT_TRACE_WIDTH + # normalize yscale to per-channel array + if np.isscalar(yscale): + yscale_array = np.full(num_channels, float(yscale)) + yscale_ref = float(yscale) + else: + yscale_array = np.asarray(yscale, dtype=float) + yscale_ref = float(np.mean(yscale_array)) + # layout constants (in viewBox units = mm) label_margin = 25 # left margin for channel labels top_margin = 5 @@ -233,7 +241,7 @@ def stackplot_svg( # compute vertical offsets in data space ticklocs, dr = _compute_channel_offsets( - yscale * data, num_channels, sensitivity=sensitivity, height=plot_height + yscale_ref * data, num_channels, sensitivity=sensitivity, height=plot_height ) def time_to_x(t): @@ -250,7 +258,7 @@ def time_to_x(t): y_data_min = 0.0 y_data_max = sensitivity * plot_height else: - scaled = yscale * data + scaled = yscale_ref * data y_data_min = scaled.min() y_data_max = (num_channels - 1) * dr + scaled.max() @@ -278,6 +286,10 @@ def data_y_to_svg(y_data): "viewBox": f"0 0 {width_mm} {height_mm}", "width": f"{width_mm}mm", "height": f"{height_mm}mm", + "data-sample-frequency": str(sample_frequency), + "data-start-time": str(start_time), + "data-seconds": str(seconds), + "data-num-channels": str(num_channels), }) # white background @@ -331,31 +343,46 @@ def data_y_to_svg(y_data): traces_g = ET.SubElement(svg, "g", {"class": "traces"}) for draw_idx, ch_idx in enumerate(channel_order): offset = ticklocs[draw_idx] - y_trace = yscale * data[:, ch_idx] + offset - y_svg = data_y_to_svg(y_trace) + baseline_y = data_y_to_svg(offset) + ch_yscale = yscale_array[ch_idx] + + if y_data_range != 0: + y_scale_factor = ch_yscale * plot_height / y_data_range + else: + y_scale_factor = 1.0 ch_g = ET.SubElement(traces_g, "g", { "class": "channel", "id": f"ch-{draw_idx}", + "transform": f"translate(0,{baseline_y:.2f})", + "data-baseline": f"{baseline_y:.2f}", + "data-channel-name": label_order[draw_idx], + "data-channel-index": str(ch_idx), }) - # channel label - label_y = data_y_to_svg(offset) + # channel label (y=0 relative to baseline via translate) ET.SubElement(ch_g, "text", { "x": f"{label_margin - 1.5:.2f}", - "y": f"{label_y:.2f}", + "y": "0", "class": "label", }).text = label_order[draw_idx] - # polyline for waveform - points_str = _format_points(t_svg, y_svg) + # polyline: y in raw data units, scale transform handles gain + data-to-SVG mapping + y_raw = data[:, ch_idx] + points_str = _format_points(t_svg, y_raw) ET.SubElement(ch_g, "polyline", { "points": points_str, "fill": "none", "stroke": linecolor, "stroke-width": str(linewidth), + "transform": f"scale(1,{y_scale_factor:.6f})", + "data-yscale": f"{y_scale_factor:.6f}", + "vector-effect": "non-scaling-stroke", }) + # annotations layer (initially empty, populated by viewer) + ET.SubElement(svg, "g", {"class": "annotations"}) + # time axis labels time_g = ET.SubElement(svg, "g", {"class": "timeaxis"}) time_label_y = top_margin + plot_height + 1.5 diff --git a/plans/eeg-clinical-viewer.md b/plans/eeg-clinical-viewer.md new file mode 100644 index 0000000..2c2cd4d --- /dev/null +++ b/plans/eeg-clinical-viewer.md @@ -0,0 +1,192 @@ +# EEG Clinical Viewer — Design Plan + +Date: 2026-03-27 + +## Use Cases (priority order) + +1. **Clinical web viewer** — hypermedia/datastar, server-driven +2. **Jupyter notebook** — static SVG + interactive iframe viewer +3. **Publication figures** — clean SVG for Illustrator/Inkscape/Affinity + +## Architecture + +### Two Web Components + +- **Component I (build now):** Thin datastar-driven element. Server renders all SVG. Client handles gain/visibility locally via SVG transforms. No shadow DOM — datastar owns reactivity. +- **Component II (deferred):** Full client-side JS with in-browser signal processing. Fetches raw signal data from server API, stores in client buffer. Requires JS port of rendering pipeline (montage math, filtering, SVG generation). Build after Component I is stable. + +### Server + +- **Framework:** FastAPI / Starlette +- **HTML/SVG generation:** fasthtml-style Python tag functions (no templates) +- **Data access:** eeghdf (HDF5) or edfarray (Rust-based EDF reader) +- **Communication:** SSE for pushing SVG fragments and annotation data to client + +## SVG Structure + +Refactor `stackplot_svg.py` to support interactive use: + +```xml + + + + Fp1-F7 + + + + + + + + + + + + + +``` + +Key changes from current implementation: +- Separate `translate` (baseline offset) from `scale` (gain) in channel transforms +- `data-*` attributes for JS discoverability (baseline, channel name, sample rate, time window) +- Separate `` layer +- Per-channel gain as array-valued `yscale` + +## Interaction Model + +### Channel Selection and Gain + +- **Select channel:** Click on channel label +- **Per-channel gain:** Up/down arrow keys when a channel is selected +- **Global sensitivity:** Up/down arrow keys when nothing is selected (excludes locked channels) +- **Sensitivity presets:** 1, 2, 3, 5, 7, 10, 15, 20, 30, 50, 70 uV/mm +- **Lock channel:** Right-click context menu on channel label + - Locked channels ignore global sensitivity changes + - Locked channels still respond to direct per-channel adjustment when selected + +### Time Navigation + +- **Page forward/back:** Left/right arrow keys (one full page) +- **Fine step:** j/k and h/l keys (1 second or 10% of page) +- **Jump to time:** Text input field +- **Jump bar:** High-level overview of events across entire recording (up to 24 hrs) + - Clicking on bar jumps to that time point + - Scrubber/scrollbar below the jump bar + - Populated lazily — server computes in background, pushes via SSE when ready +- **Page durations:** 2, 5, 10, 15, 20, 30, 60, 120, 300 seconds + +### Montage + +- **Switching:** Dropdown/select in toolbar +- **Available montages:** Everything in `montageview.py` (Double Banana, TCP transverse, Laplacian, common average, referential, etc.) plus future additions +- **Per-montage gain state:** Cached server-side per session. Each montage remembers its own channel gains, locks, and visibility. Gains persist when switching between montages. +- **Reset:** User can explicitly reset per-montage gains + +### Filters + +- **Implementation:** IIR Butterworth (default), FIR available as secondary option +- **Low-frequency filter (high-pass):** 0.1, 0.3, 0.5, 1.0, 1.5, 2.0, 5.0 Hz +- **High-frequency filter (low-pass):** 15, 20, 30, 35, 40, 50, 70, 100 Hz +- **Notch filter:** Off, 50 Hz, 60 Hz +- **Filter changes trigger server re-render** (modifies waveform data, not just display) + +### Annotations + +- **Create:** Press `a` to enter annotation mode, type label text + - Click to place point event + - Click-drag to place duration event +- **Edit:** After placement, move with cursor keys or drag +- **Display:** + - Point events: vertical lines + - Duration events: colored semi-transparent rectangles + - Text labels above/below traces + - Visible as colored markers in the jump bar +- **Scope:** Global (not channel-specific) to start +- **Storage:** Server-side, persisted to both database and BIDS-compatible TSV sidecar file + +## Data Flow (Component I — Hypermedia) + +1. **Initial load** — Server sends HTML with datastar-driven `` element + initial SVG + annotation data +2. **Navigation / montage / filter change** — Datastar event → server request → server computes new SVG via `stackplot_svg.py` → SSE push → datastar swaps SVG fragment +3. **Gain / visibility change** — Local SVG transform manipulation via datastar reactive variables, no server round-trip +4. **Annotation creation** — Sent to server → persisted to database + BIDS TSV sidecar → updated SVG pushed back +5. **Jump bar** — Server lazily computes annotation overview in background → SSE push when ready + +## Session State + +- **Scope:** Per-study, per-session +- **Initial implementation:** In-memory on server +- **Later:** Persist last session to database so it can survive server restart +- **State includes:** + - Current montage selection + - Per-montage: channel gains, locks, visibility + - Current time position + - Current page duration + - Filter settings (HP, LP, notch, filter type) + +## Annotation Storage + +### BIDS-compatible TSV sidecar (`*_events.tsv`) + +| onset | duration | trial_type | created_by | created_at | channel | +|-------|----------|------------|------------|------------|---------| +| 3.200 | 0.0 | spike | | | | +| 12.000 | 33.3 | seizure | | | | + +### Database + +Equivalent schema — same columns, queryable for jump bar and cross-study search. + +## Downsampling Strategy + +Compare two approaches empirically: +- **Points-per-pixel:** Downsample to match viewport pixel width. Client sends viewport width to server. +- **Min-max decimation:** Keep min and max per bucket to preserve spike envelope. + +Evaluate on real clinical EEG with known spikes/sharps to determine which preserves diagnostically relevant features. + +## Notebook Integration (Use Case 2) + +- `plot_eeg_svg()` — Static inline SVG via `IPython.display.SVG`. No server needed. +- `view_eeg()` — Spins up background FastAPI server, embeds interactive viewer via iframe. Reuses the same hypermedia component from Component I. + +## Publication Figures (Use Case 3) + +- Clean SVG output with no controls or interactive elements +- Suitable for import into Adobe Illustrator, Affinity Designer, Inkscape +- Uses same `stackplot_svg.py` rendering, stripped to essentials + +## Implementation Phases + +### Phase 1 — SVG Structure Changes (prerequisite) +- Refactor `stackplot_svg.py`: separate translate (baseline) from scale (gain) +- Add `data-*` attributes for JS discoverability +- Support per-channel gain as array-valued `yscale` +- Add `` layer + +### Phase 2 — Hypermedia Web Component (Component I) +- Define thin `` custom element with datastar +- Local JS: gain adjustment via SVG transform, channel show/hide +- FastAPI server: endpoints returning SVG fragments for time window + montage + filters +- SSE integration + +### Phase 3 — Clinical Viewer Features +- Time navigation (page, fine-step, jump-to, jump bar with scrubber) +- Montage switching with per-montage gain caching +- Sensitivity presets (1, 2, 3, 5, 7, 10, 15, 20, 30, 50, 70 uV/mm) +- IIR Butterworth + FIR filter options with clinical presets +- Annotations (point events, duration events, BIDS TSV + database storage) +- Downsampling comparison (points-per-pixel vs min-max) + +### Phase 4 — Publication SVG (Use Case 3) +- Strip controls, simplify output +- Ensure clean vector import into Illustrator/Inkscape/Affinity + +### Phase 5 — Notebook Integration (Use Case 2) +- `plot_eeg_svg()` static display +- `view_eeg()` with background server + iframe + +### Phase 6 — Client-Side Component (Component II, deferred) +- JS signal processing pipeline +- Client-side SVG rendering +- Client-side montage/filter application diff --git a/tests/test_stackplot_svg.py b/tests/test_stackplot_svg.py index 9043536..b2057bd 100644 --- a/tests/test_stackplot_svg.py +++ b/tests/test_stackplot_svg.py @@ -359,3 +359,136 @@ def test_save_eeg_svg(tmp_path): assert filepath.exists() root = ET.fromstring(filepath.read_text()) assert root.tag == f"{{{stackplot_svg.SVG_NS}}}svg" + + +# --- Phase 1: SVG structure tests for interactive viewer support --- + +def test_channel_has_translate_transform(): + """Each channel should have a translate transform for baseline offset.""" + signals = np.random.randn(3, 300) + svg_str = stackplot_svg.stackplot_svg(signals, sample_frequency=256.0) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + channels = root.findall(".//svg:g[@class='channel']", ns) + for ch in channels: + transform = ch.get("transform") + assert transform is not None + assert transform.startswith("translate(0,") + + +def test_polyline_has_scale_transform(): + """Each polyline should have a scale transform for gain control.""" + signals = np.random.randn(4, 500) + svg_str = stackplot_svg.stackplot_svg(signals, sample_frequency=256.0) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + polylines = root.findall(".//svg:polyline", ns) + for pl in polylines: + transform = pl.get("transform") + assert transform is not None + assert transform.startswith("scale(1,") + assert pl.get("data-yscale") is not None + assert pl.get("vector-effect") == "non-scaling-stroke" + + +def test_channel_data_attributes(): + """Channel elements should have data-baseline, data-channel-name, data-channel-index.""" + labels = ["Fp1-F7", "F7-T3", "T3-T5"] + signals = np.random.randn(3, 300) + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, ylabels=labels + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + channels = root.findall(".//svg:g[@class='channel']", ns) + assert len(channels) == 3 + channel_names = {ch.get("data-channel-name") for ch in channels} + assert channel_names == {"Fp1-F7", "F7-T3", "T3-T5"} + for ch in channels: + assert ch.get("data-baseline") is not None + assert ch.get("data-channel-index") is not None + float(ch.get("data-baseline")) # should be a valid float + + +def test_svg_root_data_attributes(): + """SVG root should have data attributes for sample-frequency, start-time, seconds, num-channels.""" + signals = np.random.randn(5, 800) + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, seconds=3.0, start_time=10.0 + ) + root = ET.fromstring(svg_str) + assert root.get("data-sample-frequency") == "256.0" + assert root.get("data-start-time") == "10.0" + assert root.get("data-seconds") == "3.0" + assert root.get("data-num-channels") == "5" + + +def test_annotations_layer_present(): + """SVG should contain an empty layer.""" + signals = np.random.randn(3, 300) + svg_str = stackplot_svg.stackplot_svg(signals, sample_frequency=256.0) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + annotations = root.findall(".//svg:g[@class='annotations']", ns) + assert len(annotations) == 1 + assert len(list(annotations[0])) == 0 # should be empty + + +def test_per_channel_yscale(): + """Array-valued yscale should produce different scale factors per channel.""" + signals = np.random.randn(3, 500) + yscale = [0.5, 1.0, 2.0] + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, yscale=yscale + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + polylines = root.findall(".//svg:polyline", ns) + scales = [float(pl.get("data-yscale")) for pl in polylines] + # with topdown=True (default), channel order is reversed for display + # but yscale_array is indexed by ch_idx, so the scale factors should differ + assert len(set(f"{s:.4f}" for s in scales)) == 3 # all three should be different + + +def test_scalar_yscale_backward_compatible(): + """Scalar yscale should still produce valid SVG with uniform scale factors.""" + signals = np.random.randn(3, 300) + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, yscale=2.0 + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + polylines = root.findall(".//svg:polyline", ns) + scales = [float(pl.get("data-yscale")) for pl in polylines] + # all channels should have the same scale factor + assert len(set(f"{s:.6f}" for s in scales)) == 1 + + +def test_translate_scale_visual_equivalence(): + """The translate+scale structure should produce the same visual result as the old baked-in approach. + + We verify by checking that the SVG-space y position of a known data point + matches what the old formula would produce. + """ + np.random.seed(42) + signals = np.random.randn(2, 100) + fs = 100.0 + svg_str = stackplot_svg.stackplot_svg(signals, fs, seconds=1.0) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + channels = root.findall(".//svg:g[@class='channel']", ns) + + # for each channel, check that translate + scale * raw_y gives correct SVG y + for ch_g in channels: + baseline = float(ch_g.get("data-baseline")) + polyline = ch_g.find("svg:polyline", ns) + yscale_val = float(polyline.get("data-yscale")) + # grab first y value from points + points = polyline.get("points") + first_point = points.split(" ")[0] + _, y_raw = first_point.split(",") + y_raw = float(y_raw) + # the visual SVG y should be: baseline + y_raw * yscale_val + y_svg = baseline + y_raw * yscale_val + # this should be within the plot area (top_margin=5, bottom = 5+187=192) + assert 0 <= y_svg <= 200 # within SVG viewBox height From 39ef286ca94d69235f9ceedb5c99013025b823ae Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Fri, 27 Mar 2026 19:52:17 -0700 Subject: [PATCH 06/17] add hypermedia EEG viewer with FastAPI, ztml, and datastar Initial server skeleton for clinical EEG viewer using hypermedia architecture: FastAPI serves SVG rendered by stackplot_svg, datastar handles SSE-driven updates for navigation, sensitivity, page duration, and filter controls. Co-Authored-By: Claude Opus 4.6 (1M context) --- eegvis/viewer/__init__.py | 1 + eegvis/viewer/app.py | 289 ++++++++++++++++++++++++++++++++ eegvis/viewer/components.py | 286 +++++++++++++++++++++++++++++++ eegvis/viewer/session.py | 129 ++++++++++++++ pyproject.toml | 3 + uv.lock | 326 ++++++++++++++++++++++++++++++++++++ 6 files changed, 1034 insertions(+) create mode 100644 eegvis/viewer/__init__.py create mode 100644 eegvis/viewer/app.py create mode 100644 eegvis/viewer/components.py create mode 100644 eegvis/viewer/session.py diff --git a/eegvis/viewer/__init__.py b/eegvis/viewer/__init__.py new file mode 100644 index 0000000..cd91d95 --- /dev/null +++ b/eegvis/viewer/__init__.py @@ -0,0 +1 @@ +"""EEG clinical viewer — hypermedia/datastar-based web viewer.""" diff --git a/eegvis/viewer/app.py b/eegvis/viewer/app.py new file mode 100644 index 0000000..8be9b00 --- /dev/null +++ b/eegvis/viewer/app.py @@ -0,0 +1,289 @@ +"""FastAPI application for the EEG clinical viewer. + +Run with: + uv run uvicorn eegvis.viewer.app:app --reload + uv run python -m eegvis.viewer.app # for demo with synthetic data +""" + +import numpy as np +from fastapi import FastAPI, Query, Request +from fastapi.responses import HTMLResponse +from datastar_py import ServerSentEventGenerator as SSE +from datastar_py.fastapi import DatastarResponse +from ztml import render + +from ..stackplot_svg import stackplot_svg, bandpass_filter, notch_filter, downsample +from .session import SessionStore, ViewerSession +from .components import viewer_page, _display_area, _jump_bar, _status_bar + +app = FastAPI(title="EEG Viewer") + +# global state +sessions = SessionStore() +_studies: dict[str, "_StudyData"] = {} + + +class _StudyData: + """Loaded EEG study data held in memory.""" + + def __init__(self, signals: np.ndarray, sample_frequency: float, + channel_labels: list[str], montage_names: list[str] | None = None): + self.signals = signals + self.sample_frequency = sample_frequency + self.channel_labels = channel_labels + self.montage_names = montage_names or ["raw"] + self.total_duration = signals.shape[1] / sample_frequency + + +def load_study(study_id: str, signals: np.ndarray, sample_frequency: float, + channel_labels: list[str], montage_names: list[str] | None = None): + """Load a study into the server for viewing.""" + _studies[study_id] = _StudyData(signals, sample_frequency, channel_labels, montage_names) + + +def _render_page_svg(study: "_StudyData", session: ViewerSession) -> str: + """Render the current page of EEG as SVG.""" + fs = study.sample_frequency + start_sample = int(session.current_time * fs) + end_sample = int((session.current_time + session.page_duration) * fs) + end_sample = min(end_sample, study.signals.shape[1]) + + page_signals = study.signals[:, start_sample:end_sample].copy() + + # apply filters + if session.highpass_freq is not None or session.lowpass_freq is not None: + page_signals = bandpass_filter( + page_signals, fs, + low_freq=session.highpass_freq, + high_freq=session.lowpass_freq, + ) + if session.notch_freq is not None: + page_signals = notch_filter(page_signals, fs, notch_freq=session.notch_freq) + + # per-channel gain from montage state + ms = session.montage_state + yscale = [ms.get_gain(label) for label in study.channel_labels] + + return stackplot_svg( + page_signals, + fs, + ylabels=study.channel_labels, + seconds=session.page_duration, + start_time=session.current_time, + yscale=yscale, + sensitivity=session.sensitivity, + show_scalebar=True, + max_samples_per_channel=2000, + ) + + +def _make_update_events(study: "_StudyData", session: ViewerSession) -> list: + """Generate datastar SSE events to update the viewer.""" + svg_content = _render_page_svg(study, session) + return [ + SSE.patch_elements(render(_display_area(svg_content)), selector="#eeg-display", mode="outer"), + SSE.patch_elements(render(_jump_bar(session, study.total_duration)), selector="#jump-bar", mode="outer"), + SSE.patch_elements(render(_status_bar(session)), selector="#status-bar", mode="outer"), + SSE.patch_signals({ + "currentTime": session.current_time, + "sensitivity": session.sensitivity, + "pageDuration": session.page_duration, + }), + ] + + +@app.get("/", response_class=HTMLResponse) +async def index(study_id: str = "demo"): + """Serve the main viewer page.""" + study = _studies.get(study_id) + if study is None: + return HTMLResponse("

Study not found

", status_code=404) + + session = sessions.create(study_id) + svg_content = _render_page_svg(study, session) + html = render(viewer_page(session, study.montage_names, svg_content, study.total_duration)) + return HTMLResponse(html) + + +@app.get("/api/navigate") +async def navigate( + session_id: str = Query(...), + action: str = Query(...), + time: float | None = Query(None), +): + """Handle navigation actions, return SSE updates.""" + session = sessions.get(session_id) + if session is None: + return DatastarResponse() + study = _studies.get(session.study_id) + if study is None: + return DatastarResponse() + + max_time = study.total_duration + step = max(1.0, session.page_duration * 0.1) + + match action: + case "page_forward": + session.page_forward(max_time) + case "page_back": + session.page_backward() + case "step_forward": + session.step_forward(step, max_time) + case "step_back": + session.step_backward(step) + case "jump": + if time is not None: + session.jump_to(time, max_time) + case "sensitivity_up": + session.adjust_global_sensitivity(-1) + case "sensitivity_down": + session.adjust_global_sensitivity(1) + + return DatastarResponse(_make_update_events(study, session)) + + +KEY_TO_ACTION = { + "ArrowRight": "page_forward", + "ArrowLeft": "page_back", + "l": "step_forward", + "k": "step_forward", + "h": "step_back", + "j": "step_back", + "ArrowUp": "sensitivity_up", + "ArrowDown": "sensitivity_down", +} + + +@app.get("/api/keydown") +async def keydown(session_id: str = Query(...), key: str = Query(...)): + """Handle keyboard events by mapping key to navigation action.""" + action = KEY_TO_ACTION.get(key) + if action is None: + return DatastarResponse() + + session = sessions.get(session_id) + if session is None: + return DatastarResponse() + study = _studies.get(session.study_id) + if study is None: + return DatastarResponse() + + max_time = study.total_duration + step = max(1.0, session.page_duration * 0.1) + + match action: + case "page_forward": + session.page_forward(max_time) + case "page_back": + session.page_backward() + case "step_forward": + session.step_forward(step, max_time) + case "step_back": + session.step_backward(step) + case "sensitivity_up": + session.adjust_global_sensitivity(-1) + case "sensitivity_down": + session.adjust_global_sensitivity(1) + + return DatastarResponse(_make_update_events(study, session)) + + +@app.get("/api/set_montage") +async def set_montage(session_id: str = Query(...), montage: str = Query(...)): + """Switch montage.""" + session = sessions.get(session_id) + if session is None: + return DatastarResponse() + session.current_montage = montage + study = _studies.get(session.study_id) + if study is None: + return DatastarResponse() + return DatastarResponse(_make_update_events(study, session)) + + +@app.get("/api/set_sensitivity") +async def set_sensitivity(session_id: str = Query(...), sensitivity: float = Query(...)): + """Set global sensitivity.""" + session = sessions.get(session_id) + if session is None: + return DatastarResponse() + session.sensitivity = sensitivity + study = _studies.get(session.study_id) + if study is None: + return DatastarResponse() + return DatastarResponse(_make_update_events(study, session)) + + +@app.get("/api/set_page_duration") +async def set_page_duration(session_id: str = Query(...), duration: float = Query(...)): + """Set page duration.""" + session = sessions.get(session_id) + if session is None: + return DatastarResponse() + session.page_duration = duration + study = _studies.get(session.study_id) + if study is None: + return DatastarResponse() + return DatastarResponse(_make_update_events(study, session)) + + +@app.get("/api/set_filter") +async def set_filter( + session_id: str = Query(...), + type: str = Query(...), + value: str = Query(...), +): + """Set filter parameters.""" + session = sessions.get(session_id) + if session is None: + return DatastarResponse() + + freq = None if value == "none" else float(value) + match type: + case "highpass": + session.highpass_freq = freq + case "lowpass": + session.lowpass_freq = freq + case "notch": + session.notch_freq = freq + + study = _studies.get(session.study_id) + if study is None: + return DatastarResponse() + return DatastarResponse(_make_update_events(study, session)) + + +def create_demo_study(): + """Create a synthetic EEG study for demo/testing.""" + fs = 256.0 + duration = 120.0 # 2 minutes + num_channels = 19 + num_samples = int(fs * duration) + + channel_labels = [ + "Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4", + "O1", "O2", "F7", "F8", "T3", "T4", "T5", "T6", + "Fz", "Cz", "Pz", + ] + + rng = np.random.default_rng(42) + t = np.arange(num_samples) / fs + + signals = np.zeros((num_channels, num_samples)) + for i in range(num_channels): + # background: mix of alpha (10Hz) and theta (6Hz) with noise + alpha = rng.uniform(10, 40) * np.sin(2 * np.pi * (10 + rng.uniform(-1, 1)) * t + rng.uniform(0, 2 * np.pi)) + theta = rng.uniform(5, 15) * np.sin(2 * np.pi * (6 + rng.uniform(-0.5, 0.5)) * t + rng.uniform(0, 2 * np.pi)) + noise = rng.normal(0, 5, num_samples) + # 60Hz line noise + line_noise = 3 * np.sin(2 * np.pi * 60 * t) + signals[i, :] = alpha + theta + noise + line_noise + + load_study("demo", signals, fs, channel_labels) + + +if __name__ == "__main__": + import uvicorn + create_demo_study() + print("Starting EEG viewer at http://localhost:8000") + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/eegvis/viewer/components.py b/eegvis/viewer/components.py new file mode 100644 index 0000000..a306869 --- /dev/null +++ b/eegvis/viewer/components.py @@ -0,0 +1,286 @@ +"""ztml HTML components for the EEG viewer.""" + +from ztml import ( + Body, Button, Div, Fragment, H1, Head, Html, Label, Meta, + Option, P, Raw, RawCss, Script, Select, Span, Style, Title, Input, +) + +from .session import ( + SENSITIVITY_PRESETS, PAGE_DURATION_PRESETS, + ViewerSession, +) + +DATASTAR_CDN = "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-RC.8/bundles/datastar.js" + + +def page_shell(*children): + """Full HTML document shell with datastar loaded.""" + return Fragment( + Raw(""), + Html( + Head( + Meta().charset("utf-8"), + Meta().name("viewport").content("width=device-width, initial-scale=1"), + Title("EEG Viewer"), + Script().src(DATASTAR_CDN).type("module"), + _viewer_styles(), + ), + Body(*children), + ), + ) + + +def _viewer_styles(): + """Inline CSS for the viewer layout.""" + return Style(RawCss(""" + * { box-sizing: border-box; margin: 0; padding: 0; } + body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; } + .viewer-root { max-width: 1400px; margin: 0 auto; padding: 8px; } + .toolbar { + display: flex; gap: 8px; align-items: center; flex-wrap: wrap; + padding: 8px; background: white; border: 1px solid #ddd; + border-radius: 4px; margin-bottom: 8px; + } + .toolbar label { font-size: 13px; color: #555; } + .toolbar select, .toolbar input, .toolbar button { + font-size: 13px; padding: 4px 8px; border: 1px solid #ccc; + border-radius: 3px; background: white; + } + .toolbar button { cursor: pointer; } + .toolbar button:hover { background: #eee; } + .toolbar .separator { width: 1px; height: 24px; background: #ddd; } + .display-area { + background: white; border: 1px solid #ddd; border-radius: 4px; + padding: 4px; overflow: hidden; + } + .display-area svg { width: 100%; height: auto; } + .status-bar { + display: flex; justify-content: space-between; align-items: center; + padding: 4px 8px; font-size: 12px; color: #777; + background: white; border: 1px solid #ddd; + border-radius: 4px; margin-top: 8px; + } + .jump-bar { + height: 24px; background: #eee; border: 1px solid #ddd; + border-radius: 3px; margin-top: 8px; position: relative; + cursor: pointer; + } + .jump-bar .position-marker { + position: absolute; top: 0; height: 100%; + background: rgba(66, 133, 244, 0.3); border-left: 2px solid #4285f4; + } + """)) + + +def viewer_page(session: ViewerSession, montage_names: list[str], svg_content: str, total_duration: float): + """Build the full viewer page.""" + signals = { + "sensitivity": session.sensitivity, + "pageDuration": session.page_duration, + "currentTime": session.current_time, + "montage": f"'{session.current_montage}'", + "selectedChannel": "null", + "_totalDuration": total_duration, + } + signals_str = ", ".join(f"{k}: {v}" for k, v in signals.items()) + + return page_shell( + Div( + _toolbar(session, montage_names), + _display_area(svg_content), + _jump_bar(session, total_duration), + _status_bar(session), + ).cls("viewer-root").attr( + "data-signals", f"{{{signals_str}}}" + ).attr( + "tabindex", "0" + ).attr( + "data-on:keydown", _keydown_handler(session.session_id) + ), + ) + + +def _toolbar(session: ViewerSession, montage_names: list[str]): + """Toolbar with montage, sensitivity, page duration, navigation, and filter controls.""" + return Div( + # montage selector + Label("Montage:"), + _montage_select(session, montage_names), + _separator(), + + # sensitivity + Label("Sensitivity:"), + _sensitivity_select(session), + _separator(), + + # page duration + Label("Page:"), + _page_duration_select(session), + _separator(), + + # navigation + Button("\u25c0\u25c0").title("Previous page (Left arrow)").attr( + "data-on:click", f"@get('/api/navigate?session_id={session.session_id}&action=page_back')" + ), + Button("\u25c0").title("Step back (h/j)").attr( + "data-on:click", f"@get('/api/navigate?session_id={session.session_id}&action=step_back')" + ), + Button("\u25b6").title("Step forward (k/l)").attr( + "data-on:click", f"@get('/api/navigate?session_id={session.session_id}&action=step_forward')" + ), + Button("\u25b6\u25b6").title("Next page (Right arrow)").attr( + "data-on:click", f"@get('/api/navigate?session_id={session.session_id}&action=page_forward')" + ), + _separator(), + + # jump to time + Label("Go to:"), + Input().type("number").attr("data-bind", "currentTime").attr( + "min", "0" + ).attr( + "step", "1" + ).attr( + "data-on:change", f"@get('/api/navigate?session_id={session.session_id}&action=jump&time=' + $currentTime)" + ).style("width: 70px"), + Label("s"), + _separator(), + + # filters + Label("HP:"), + _filter_select( + "highpass", session.session_id, + [None, 0.1, 0.3, 0.5, 1.0, 1.5, 2.0, 5.0], + session.highpass_freq, + ), + Label("LP:"), + _filter_select( + "lowpass", session.session_id, + [None, 15, 20, 30, 35, 40, 50, 70, 100], + session.lowpass_freq, + ), + Label("Notch:"), + _filter_select( + "notch", session.session_id, + [None, 50, 60], + session.notch_freq, + ), + ).cls("toolbar") + + +def _montage_select(session: ViewerSession, montage_names: list[str]): + """Montage dropdown.""" + options = [] + for name in montage_names: + opt = Option(name).value(name) + if name == session.current_montage: + opt = opt.selected(True) + options.append(opt) + return Select(*options).attr( + "data-bind", "montage" + ).attr( + "data-on:change", + f"@get('/api/set_montage?session_id={session.session_id}&montage=' + $montage)", + ) + + +def _sensitivity_select(session: ViewerSession): + """Sensitivity preset dropdown.""" + options = [] + for s in SENSITIVITY_PRESETS: + opt = Option(f"{s} \u00b5V/mm").value(str(s)) + if s == session.sensitivity: + opt = opt.selected(True) + options.append(opt) + return Select(*options).attr( + "data-bind", "sensitivity" + ).attr( + "data-on:change", + f"@get('/api/set_sensitivity?session_id={session.session_id}&sensitivity=' + $sensitivity)", + ) + + +def _page_duration_select(session: ViewerSession): + """Page duration dropdown.""" + options = [] + for d in PAGE_DURATION_PRESETS: + label = f"{d}s" if d < 60 else f"{d // 60}m" + opt = Option(label).value(str(d)) + if d == session.page_duration: + opt = opt.selected(True) + options.append(opt) + return Select(*options).attr( + "data-bind", "pageDuration" + ).attr( + "data-on:change", + f"@get('/api/set_page_duration?session_id={session.session_id}&duration=' + $pageDuration)", + ) + + +def _filter_select(filter_type: str, session_id: str, values: list, current: float | None): + """Filter preset dropdown.""" + options = [] + for v in values: + label = "Off" if v is None else f"{v} Hz" + val_str = "none" if v is None else str(v) + opt = Option(label).value(val_str) + if v == current or (v is None and current is None): + opt = opt.selected(True) + options.append(opt) + return Select(*options).attr( + "data-on:change", + f"@get('/api/set_filter?session_id={session_id}&type={filter_type}&value=' + evt.target.value)", + ) + + +def _separator(): + return Div().cls("separator") + + +def _display_area(svg_content: str): + """Main SVG display area.""" + return Div( + Raw(svg_content), + ).cls("display-area").id("eeg-display") + + +def _jump_bar(session: ViewerSession, total_duration: float): + """Jump bar showing position in recording.""" + if total_duration > 0: + left_pct = session.current_time / total_duration * 100 + width_pct = session.page_duration / total_duration * 100 + else: + left_pct = 0 + width_pct = 100 + return Div( + Div().cls("position-marker").style( + f"left: {left_pct:.1f}%; width: {width_pct:.1f}%" + ), + ).cls("jump-bar").id("jump-bar").attr( + "data-on:click", + f"@get('/api/navigate?session_id={session.session_id}&action=jump&time=' + " + f"Math.round(evt.offsetX / evt.target.offsetWidth * {total_duration}))", + ) + + +def _status_bar(session: ViewerSession): + """Status bar showing current state.""" + time_str = f"{session.current_time:.1f}s - {session.current_time + session.page_duration:.1f}s" + return Div( + Span(f"Time: {time_str}"), + Span(f"Montage: {session.current_montage}"), + Span(f"Sensitivity: {session.sensitivity} \u00b5V/mm"), + Span(f"Filters: HP {session.highpass_freq or 'Off'} / LP {session.lowpass_freq or 'Off'} / Notch {session.notch_freq or 'Off'}"), + ).cls("status-bar").id("status-bar") + + +def _keydown_handler(session_id: str): + """Datastar expression for keyboard navigation. + + Sends the key to the server which maps it to an action. + Only sends for keys we care about to avoid unnecessary requests. + """ + keys = "ArrowRight,ArrowLeft,ArrowUp,ArrowDown,h,j,k,l" + return ( + f"'{keys}'.split(',').includes(evt.key) && " + f"@get('/api/keydown?session_id={session_id}&key=' + evt.key)" + ) diff --git a/eegvis/viewer/session.py b/eegvis/viewer/session.py new file mode 100644 index 0000000..eb710e7 --- /dev/null +++ b/eegvis/viewer/session.py @@ -0,0 +1,129 @@ +"""In-memory session state for the EEG viewer. + +Each session tracks per-study viewer state: current position, montage, +filters, and per-montage channel gain/lock/visibility settings. +""" + +import uuid +from dataclasses import dataclass, field + + +# Standard clinical sensitivity presets (µV/mm) +SENSITIVITY_PRESETS = [1, 2, 3, 5, 7, 10, 15, 20, 30, 50, 70] + +# Standard page duration presets (seconds) +PAGE_DURATION_PRESETS = [2, 5, 10, 15, 20, 30, 60, 120, 300] + +DEFAULT_SENSITIVITY = 10 # µV/mm +DEFAULT_PAGE_DURATION = 10 # seconds + + +@dataclass +class MontageState: + """Per-montage viewer state: channel gains, locks, visibility.""" + + channel_gains: dict[str, float] = field(default_factory=dict) + channel_locks: dict[str, bool] = field(default_factory=dict) + channel_visible: dict[str, bool] = field(default_factory=dict) + + def get_gain(self, channel_name: str) -> float: + return self.channel_gains.get(channel_name, 1.0) + + def set_gain(self, channel_name: str, gain: float): + self.channel_gains[channel_name] = gain + + def is_locked(self, channel_name: str) -> bool: + return self.channel_locks.get(channel_name, False) + + def toggle_lock(self, channel_name: str): + self.channel_locks[channel_name] = not self.is_locked(channel_name) + + def is_visible(self, channel_name: str) -> bool: + return self.channel_visible.get(channel_name, True) + + def toggle_visible(self, channel_name: str): + self.channel_visible[channel_name] = not self.is_visible(channel_name) + + def reset(self): + self.channel_gains.clear() + self.channel_locks.clear() + self.channel_visible.clear() + + +@dataclass +class ViewerSession: + """Per-study, per-session viewer state.""" + + session_id: str = field(default_factory=lambda: str(uuid.uuid4())) + study_id: str = "" + + # navigation + current_time: float = 0.0 + page_duration: float = DEFAULT_PAGE_DURATION + + # montage + current_montage: str = "raw" + montage_states: dict[str, MontageState] = field(default_factory=dict) + + # global sensitivity (µV/mm) + sensitivity: float = DEFAULT_SENSITIVITY + + # filters + highpass_freq: float | None = 1.0 + lowpass_freq: float | None = 70.0 + notch_freq: float | None = None + filter_type: str = "iir" # "iir" (Butterworth) or "fir" + + @property + def montage_state(self) -> MontageState: + if self.current_montage not in self.montage_states: + self.montage_states[self.current_montage] = MontageState() + return self.montage_states[self.current_montage] + + def page_forward(self, max_time: float): + self.current_time = min(self.current_time + self.page_duration, max_time - self.page_duration) + self.current_time = max(0.0, self.current_time) + + def page_backward(self): + self.current_time = max(0.0, self.current_time - self.page_duration) + + def step_forward(self, step: float, max_time: float): + self.current_time = min(self.current_time + step, max_time - self.page_duration) + self.current_time = max(0.0, self.current_time) + + def step_backward(self, step: float): + self.current_time = max(0.0, self.current_time - step) + + def jump_to(self, time: float, max_time: float): + self.current_time = max(0.0, min(time, max_time - self.page_duration)) + + def adjust_global_sensitivity(self, direction: int): + """Move sensitivity up or down through presets. + + direction: +1 = increase (less sensitive, bigger number), + -1 = decrease (more sensitive, smaller number) + """ + try: + idx = SENSITIVITY_PRESETS.index(self.sensitivity) + except ValueError: + idx = SENSITIVITY_PRESETS.index(DEFAULT_SENSITIVITY) + new_idx = max(0, min(len(SENSITIVITY_PRESETS) - 1, idx + direction)) + self.sensitivity = SENSITIVITY_PRESETS[new_idx] + + +class SessionStore: + """In-memory store for viewer sessions, keyed by session_id.""" + + def __init__(self): + self._sessions: dict[str, ViewerSession] = {} + + def create(self, study_id: str) -> ViewerSession: + session = ViewerSession(study_id=study_id) + self._sessions[session.session_id] = session + return session + + def get(self, session_id: str) -> ViewerSession | None: + return self._sessions.get(session_id) + + def delete(self, session_id: str): + self._sessions.pop(session_id, None) diff --git a/pyproject.toml b/pyproject.toml index 422f80c..e910b6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ dependencies = [ "bokeh>=0.12.16", # required for bokeh backend "ipywidgets>=7.0", "panel", # needed for more elaborate ui/web dashboard + "ztml>=0.2.4", + "datastar-py>=0.8.0", + "fastapi>=0.135.2", ] # may want to break this out into different backends diff --git a/uv.lock b/uv.lock index 93935f9..af24109 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,36 @@ resolution-markers = [ "sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "appnope" version = "0.1.4" @@ -125,6 +155,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -185,6 +227,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "datastar-py" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e3/28f8b1ba914302378fdec7050a11ca69e937f000ba16b9c9f8aaf8f8667e/datastar_py-0.8.0.tar.gz", hash = "sha256:a6893608da32378ae22640c115c80e50b2e905db1a2adca840d1ee6b1009b308", size = 137445, upload-time = "2025-12-19T19:43:53.802Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9c/2d805013718a5e90e1574e54b5eef6e64955c7c0f319d1ef9fc4dcdd6a79/datastar_py-0.8.0-py3-none-any.whl", hash = "sha256:637b557d163ad31d1b1c8ecf13c02ae33cd5134ec3f606ed38d0cefcac675d54", size = 19023, upload-time = "2025-12-19T19:43:52.626Z" }, +] + [[package]] name = "debugpy" version = "1.8.20" @@ -232,13 +283,16 @@ name = "eegvis" source = { editable = "." } dependencies = [ { name = "bokeh" }, + { name = "datastar-py" }, { name = "eegml-signal" }, + { name = "fastapi" }, { name = "ipywidgets" }, { name = "matplotlib" }, { name = "numpy" }, { name = "panel" }, { name = "scipy" }, { name = "xarray" }, + { name = "ztml" }, ] [package.optional-dependencies] @@ -258,8 +312,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "bokeh", specifier = ">=0.12.16" }, + { name = "datastar-py", specifier = ">=0.8.0" }, { name = "eeghdf", marker = "extra == 'eeghdf'" }, { name = "eegml-signal", git = "https://github.com/eegml/eegml-signal.git?rev=master" }, + { name = "fastapi", specifier = ">=0.135.2" }, { name = "ipywidgets", specifier = ">=7.0" }, { name = "matplotlib", specifier = ">=3.2" }, { name = "numpy" }, @@ -267,6 +323,7 @@ requires-dist = [ { name = "pyedflib", marker = "extra == 'pyedflib'", specifier = "<=0.1.22" }, { name = "scipy" }, { name = "xarray" }, + { name = "ztml", specifier = ">=0.2.4" }, ] provides-extras = ["eeghdf", "pyedflib"] @@ -285,6 +342,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fastapi" +version = "0.135.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, +] + [[package]] name = "fonttools" version = "4.61.1" @@ -319,6 +392,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "h5py" version = "3.15.1" @@ -338,6 +420,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/b7/4a806f85d62c20157e62e58e03b27513dc9c55499768530acc4f4c5ce4be/h5py-3.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:a6d8c5a05a76aca9a494b4c53ce8a9c29023b7f64f625c6ce1841e92a362ccdf", size = 2465544, upload-time = "2025-10-16T10:35:25.695Z" }, ] +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -429,6 +526,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jedi" version = "0.19.2" @@ -906,6 +1012,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + [[package]] name = "pyedflib" version = "0.1.22" @@ -961,6 +1121,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + [[package]] name = "pyviz-comms" version = "3.0.6" @@ -1099,6 +1277,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + [[package]] name = "tornado" version = "6.5.4" @@ -1148,6 +1338,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2025.3" @@ -1175,6 +1377,84 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + [[package]] name = "wcwidth" version = "0.6.0" @@ -1193,6 +1473,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, ] +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "widgetsnbextension" version = "4.0.15" @@ -1224,3 +1531,22 @@ sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/022795fc1201e7c29 wheels = [ { url = "https://files.pythonhosted.org/packages/ef/5c/2c189d18d495dd0fa3f27ccc60762bbc787eed95b9b0147266e72bb76585/xyzservices-2025.11.0-py3-none-any.whl", hash = "sha256:de66a7599a8d6dad63980b77defd1d8f5a5a9cb5fc8774ea1c6e89ca7c2a3d2f", size = 93916, upload-time = "2025-11-22T11:31:50.525Z" }, ] + +[[package]] +name = "ztml" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "itsdangerous" }, + { name = "python-multipart" }, + { name = "starlette" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/05/67cb86e3d16a78a78e4b160ca943dfcb8eec378102951529c9145a9f4f27/ztml-0.2.4.tar.gz", hash = "sha256:f7009cda8f6e4815502069595a07ca3a62fa2d3504455ab7103ea46d8dc5d2fe", size = 56543, upload-time = "2026-03-19T02:23:49.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/2d/11692d3f187795d6f056206a400288bd03f2c9f0e9cc60e894e2e33ad7a9/ztml-0.2.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:87d249a03755f92fa1381cd7c5d4ae26418932ab6a83e77cf5c848ed5062ea66", size = 1315313, upload-time = "2026-03-19T02:23:41.571Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1a/21b76ead2e0cdb8b22778fd37b7c0bab6d7d460d1d6b03ecda53c82fcbe2/ztml-0.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:591a3c195d3587baf898b93fded57b6c247acbbc789918d34c797f43e1e2737b", size = 1380492, upload-time = "2026-03-19T02:23:43.309Z" }, + { url = "https://files.pythonhosted.org/packages/77/51/6765c3a0aa0961ce4288f696fc334394420b0871d8b23de071f0673e2ba3/ztml-0.2.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d2c38d04f7c7723e082782ae7507a59013f0e36be52d658e2c107397d7ffdd0", size = 1578638, upload-time = "2026-03-19T02:23:44.749Z" }, + { url = "https://files.pythonhosted.org/packages/db/35/95e925c8f039029052a08325e0725c8007820ff6faefaa919d755a9b3e89/ztml-0.2.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fbfa02aba476791491d55af0bf45b68a594fd20eae852dda4ffbdcf16b3fe49", size = 1426897, upload-time = "2026-03-19T02:23:46.329Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/289d1a4612f2d631bebd87f0c9bf02e1875b4d0bd201910fc35721c452a2/ztml-0.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:b54ebea43b0d68955a8aede7cb2b4339f0c98318d35fca961da79cde1c8276e5", size = 1143858, upload-time = "2026-03-19T02:23:48.06Z" }, +] From efd03a933415e0f0d72708caca7cb456fe95958d Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Fri, 27 Mar 2026 19:52:27 -0700 Subject: [PATCH 07/17] apply ruff formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- eegvis/stackplot_svg.py | 235 ++++++++++++++++++++++-------------- eegvis/viewer/app.py | 90 ++++++++++---- eegvis/viewer/components.py | 197 +++++++++++++++++++----------- eegvis/viewer/session.py | 4 +- tests/test_stackplot_svg.py | 69 +++++++---- 5 files changed, 392 insertions(+), 203 deletions(-) diff --git a/eegvis/stackplot_svg.py b/eegvis/stackplot_svg.py index 2d105fa..2a8ca7a 100644 --- a/eegvis/stackplot_svg.py +++ b/eegvis/stackplot_svg.py @@ -249,7 +249,9 @@ def time_to_x(t): return label_margin + (t - start_time) / seconds * plot_width # compute mapping from data coordinates to SVG coordinates - t_data = start_time + seconds * np.arange(num_samples, dtype=float) / max(num_samples - 1, 1) + t_data = start_time + seconds * np.arange(num_samples, dtype=float) / max( + num_samples - 1, 1 + ) t_svg = time_to_x(t_data) # y axis: data values are offset by ticklocs, then mapped to SVG y @@ -281,23 +283,30 @@ def data_y_to_svg(y_data): label_order = list(reversed(label_order)) # build SVG - svg = ET.Element("svg", { - "xmlns": SVG_NS, - "viewBox": f"0 0 {width_mm} {height_mm}", - "width": f"{width_mm}mm", - "height": f"{height_mm}mm", - "data-sample-frequency": str(sample_frequency), - "data-start-time": str(start_time), - "data-seconds": str(seconds), - "data-num-channels": str(num_channels), - }) + svg = ET.Element( + "svg", + { + "xmlns": SVG_NS, + "viewBox": f"0 0 {width_mm} {height_mm}", + "width": f"{width_mm}mm", + "height": f"{height_mm}mm", + "data-sample-frequency": str(sample_frequency), + "data-start-time": str(start_time), + "data-seconds": str(seconds), + "data-num-channels": str(num_channels), + }, + ) # white background - ET.SubElement(svg, "rect", { - "width": "100%", - "height": "100%", - "fill": "white", - }) + ET.SubElement( + svg, + "rect", + { + "width": "100%", + "height": "100%", + "fill": "white", + }, + ) # style element for text defaults style = ET.SubElement(svg, "style") @@ -313,31 +322,41 @@ def data_y_to_svg(y_data): grid_g = ET.SubElement(svg, "g", {"class": "grid"}) grid_times = np.arange( np.ceil(start_time / grid_interval) * grid_interval, - start_time + seconds + grid_interval * 0.01, # small epsilon for inclusive end + start_time + + seconds + + grid_interval * 0.01, # small epsilon for inclusive end grid_interval, ) grid_times = grid_times[grid_times <= start_time + seconds] for t in grid_times: x = time_to_x(t) - ET.SubElement(grid_g, "line", { - "x1": f"{x:.2f}", - "y1": f"{top_margin:.2f}", - "x2": f"{x:.2f}", - "y2": f"{top_margin + plot_height:.2f}", - "stroke": DEFAULT_GRID_COLOR, - "stroke-width": DEFAULT_GRID_WIDTH, - }) + ET.SubElement( + grid_g, + "line", + { + "x1": f"{x:.2f}", + "y1": f"{top_margin:.2f}", + "x2": f"{x:.2f}", + "y2": f"{top_margin + plot_height:.2f}", + "stroke": DEFAULT_GRID_COLOR, + "stroke-width": DEFAULT_GRID_WIDTH, + }, + ) # plot border - ET.SubElement(svg, "rect", { - "x": f"{label_margin:.2f}", - "y": f"{top_margin:.2f}", - "width": f"{plot_width:.2f}", - "height": f"{plot_height:.2f}", - "fill": "none", - "stroke": "#999999", - "stroke-width": "0.2", - }) + ET.SubElement( + svg, + "rect", + { + "x": f"{label_margin:.2f}", + "y": f"{top_margin:.2f}", + "width": f"{plot_width:.2f}", + "height": f"{plot_height:.2f}", + "fill": "none", + "stroke": "#999999", + "stroke-width": "0.2", + }, + ) # channel traces and labels traces_g = ET.SubElement(svg, "g", {"class": "traces"}) @@ -351,34 +370,46 @@ def data_y_to_svg(y_data): else: y_scale_factor = 1.0 - ch_g = ET.SubElement(traces_g, "g", { - "class": "channel", - "id": f"ch-{draw_idx}", - "transform": f"translate(0,{baseline_y:.2f})", - "data-baseline": f"{baseline_y:.2f}", - "data-channel-name": label_order[draw_idx], - "data-channel-index": str(ch_idx), - }) + ch_g = ET.SubElement( + traces_g, + "g", + { + "class": "channel", + "id": f"ch-{draw_idx}", + "transform": f"translate(0,{baseline_y:.2f})", + "data-baseline": f"{baseline_y:.2f}", + "data-channel-name": label_order[draw_idx], + "data-channel-index": str(ch_idx), + }, + ) # channel label (y=0 relative to baseline via translate) - ET.SubElement(ch_g, "text", { - "x": f"{label_margin - 1.5:.2f}", - "y": "0", - "class": "label", - }).text = label_order[draw_idx] + ET.SubElement( + ch_g, + "text", + { + "x": f"{label_margin - 1.5:.2f}", + "y": "0", + "class": "label", + }, + ).text = label_order[draw_idx] # polyline: y in raw data units, scale transform handles gain + data-to-SVG mapping y_raw = data[:, ch_idx] points_str = _format_points(t_svg, y_raw) - ET.SubElement(ch_g, "polyline", { - "points": points_str, - "fill": "none", - "stroke": linecolor, - "stroke-width": str(linewidth), - "transform": f"scale(1,{y_scale_factor:.6f})", - "data-yscale": f"{y_scale_factor:.6f}", - "vector-effect": "non-scaling-stroke", - }) + ET.SubElement( + ch_g, + "polyline", + { + "points": points_str, + "fill": "none", + "stroke": linecolor, + "stroke-width": str(linewidth), + "transform": f"scale(1,{y_scale_factor:.6f})", + "data-yscale": f"{y_scale_factor:.6f}", + "vector-effect": "non-scaling-stroke", + }, + ) # annotations layer (initially empty, populated by viewer) ET.SubElement(svg, "g", {"class": "annotations"}) @@ -394,11 +425,15 @@ def data_y_to_svg(y_data): for t in label_times: x = time_to_x(t) - ET.SubElement(time_g, "text", { - "x": f"{x:.2f}", - "y": f"{time_label_y:.2f}", - "class": "time-label", - }).text = f"{t:.4g}s" + ET.SubElement( + time_g, + "text", + { + "x": f"{x:.2f}", + "y": f"{time_label_y:.2f}", + "class": "time-label", + }, + ).text = f"{t:.4g}s" # vertical scale bar if show_scalebar: @@ -418,40 +453,56 @@ def data_y_to_svg(y_data): sb_g = ET.SubElement(svg, "g", {"class": "scalebar"}) # vertical line - ET.SubElement(sb_g, "line", { - "x1": f"{sb_x:.2f}", - "y1": f"{sb_top_svg:.2f}", - "x2": f"{sb_x:.2f}", - "y2": f"{sb_bot_svg:.2f}", - "stroke": "black", - "stroke-width": "0.3", - }) + ET.SubElement( + sb_g, + "line", + { + "x1": f"{sb_x:.2f}", + "y1": f"{sb_top_svg:.2f}", + "x2": f"{sb_x:.2f}", + "y2": f"{sb_bot_svg:.2f}", + "stroke": "black", + "stroke-width": "0.3", + }, + ) # top end cap cap_w = 1 - ET.SubElement(sb_g, "line", { - "x1": f"{sb_x - cap_w:.2f}", - "y1": f"{sb_top_svg:.2f}", - "x2": f"{sb_x + cap_w:.2f}", - "y2": f"{sb_top_svg:.2f}", - "stroke": "black", - "stroke-width": "0.3", - }) + ET.SubElement( + sb_g, + "line", + { + "x1": f"{sb_x - cap_w:.2f}", + "y1": f"{sb_top_svg:.2f}", + "x2": f"{sb_x + cap_w:.2f}", + "y2": f"{sb_top_svg:.2f}", + "stroke": "black", + "stroke-width": "0.3", + }, + ) # bottom end cap - ET.SubElement(sb_g, "line", { - "x1": f"{sb_x - cap_w:.2f}", - "y1": f"{sb_bot_svg:.2f}", - "x2": f"{sb_x + cap_w:.2f}", - "y2": f"{sb_bot_svg:.2f}", - "stroke": "black", - "stroke-width": "0.3", - }) + ET.SubElement( + sb_g, + "line", + { + "x1": f"{sb_x - cap_w:.2f}", + "y1": f"{sb_bot_svg:.2f}", + "x2": f"{sb_x + cap_w:.2f}", + "y2": f"{sb_bot_svg:.2f}", + "stroke": "black", + "stroke-width": "0.3", + }, + ) # label sb_label_y = (sb_top_svg + sb_bot_svg) / 2.0 - ET.SubElement(sb_g, "text", { - "x": f"{sb_x + cap_w + 1:.2f}", - "y": f"{sb_label_y:.2f}", - "class": "scalebar-label", - }).text = f"{scalebar_height:.4g}{scalebar_units}" + ET.SubElement( + sb_g, + "text", + { + "x": f"{sb_x + cap_w + 1:.2f}", + "y": f"{sb_label_y:.2f}", + "class": "scalebar-label", + }, + ).text = f"{scalebar_height:.4g}{scalebar_units}" # serialize ET.indent(svg, space=" ") @@ -549,7 +600,9 @@ def eeg_to_svg( """ # 1. downsample if target_frequency is not None: - signals, sample_frequency = downsample(signals, sample_frequency, target_frequency) + signals, sample_frequency = downsample( + signals, sample_frequency, target_frequency + ) # 2. montage derivation ylabels = kwargs.pop("ylabels", None) diff --git a/eegvis/viewer/app.py b/eegvis/viewer/app.py index 8be9b00..284542a 100644 --- a/eegvis/viewer/app.py +++ b/eegvis/viewer/app.py @@ -26,8 +26,13 @@ class _StudyData: """Loaded EEG study data held in memory.""" - def __init__(self, signals: np.ndarray, sample_frequency: float, - channel_labels: list[str], montage_names: list[str] | None = None): + def __init__( + self, + signals: np.ndarray, + sample_frequency: float, + channel_labels: list[str], + montage_names: list[str] | None = None, + ): self.signals = signals self.sample_frequency = sample_frequency self.channel_labels = channel_labels @@ -35,10 +40,17 @@ def __init__(self, signals: np.ndarray, sample_frequency: float, self.total_duration = signals.shape[1] / sample_frequency -def load_study(study_id: str, signals: np.ndarray, sample_frequency: float, - channel_labels: list[str], montage_names: list[str] | None = None): +def load_study( + study_id: str, + signals: np.ndarray, + sample_frequency: float, + channel_labels: list[str], + montage_names: list[str] | None = None, +): """Load a study into the server for viewing.""" - _studies[study_id] = _StudyData(signals, sample_frequency, channel_labels, montage_names) + _studies[study_id] = _StudyData( + signals, sample_frequency, channel_labels, montage_names + ) def _render_page_svg(study: "_StudyData", session: ViewerSession) -> str: @@ -53,7 +65,8 @@ def _render_page_svg(study: "_StudyData", session: ViewerSession) -> str: # apply filters if session.highpass_freq is not None or session.lowpass_freq is not None: page_signals = bandpass_filter( - page_signals, fs, + page_signals, + fs, low_freq=session.highpass_freq, high_freq=session.lowpass_freq, ) @@ -81,14 +94,24 @@ def _make_update_events(study: "_StudyData", session: ViewerSession) -> list: """Generate datastar SSE events to update the viewer.""" svg_content = _render_page_svg(study, session) return [ - SSE.patch_elements(render(_display_area(svg_content)), selector="#eeg-display", mode="outer"), - SSE.patch_elements(render(_jump_bar(session, study.total_duration)), selector="#jump-bar", mode="outer"), - SSE.patch_elements(render(_status_bar(session)), selector="#status-bar", mode="outer"), - SSE.patch_signals({ - "currentTime": session.current_time, - "sensitivity": session.sensitivity, - "pageDuration": session.page_duration, - }), + SSE.patch_elements( + render(_display_area(svg_content)), selector="#eeg-display", mode="outer" + ), + SSE.patch_elements( + render(_jump_bar(session, study.total_duration)), + selector="#jump-bar", + mode="outer", + ), + SSE.patch_elements( + render(_status_bar(session)), selector="#status-bar", mode="outer" + ), + SSE.patch_signals( + { + "currentTime": session.current_time, + "sensitivity": session.sensitivity, + "pageDuration": session.page_duration, + } + ), ] @@ -101,7 +124,9 @@ async def index(study_id: str = "demo"): session = sessions.create(study_id) svg_content = _render_page_svg(study, session) - html = render(viewer_page(session, study.montage_names, svg_content, study.total_duration)) + html = render( + viewer_page(session, study.montage_names, svg_content, study.total_duration) + ) return HTMLResponse(html) @@ -202,7 +227,9 @@ async def set_montage(session_id: str = Query(...), montage: str = Query(...)): @app.get("/api/set_sensitivity") -async def set_sensitivity(session_id: str = Query(...), sensitivity: float = Query(...)): +async def set_sensitivity( + session_id: str = Query(...), sensitivity: float = Query(...) +): """Set global sensitivity.""" session = sessions.get(session_id) if session is None: @@ -261,9 +288,25 @@ def create_demo_study(): num_samples = int(fs * duration) channel_labels = [ - "Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4", - "O1", "O2", "F7", "F8", "T3", "T4", "T5", "T6", - "Fz", "Cz", "Pz", + "Fp1", + "Fp2", + "F3", + "F4", + "C3", + "C4", + "P3", + "P4", + "O1", + "O2", + "F7", + "F8", + "T3", + "T4", + "T5", + "T6", + "Fz", + "Cz", + "Pz", ] rng = np.random.default_rng(42) @@ -272,8 +315,12 @@ def create_demo_study(): signals = np.zeros((num_channels, num_samples)) for i in range(num_channels): # background: mix of alpha (10Hz) and theta (6Hz) with noise - alpha = rng.uniform(10, 40) * np.sin(2 * np.pi * (10 + rng.uniform(-1, 1)) * t + rng.uniform(0, 2 * np.pi)) - theta = rng.uniform(5, 15) * np.sin(2 * np.pi * (6 + rng.uniform(-0.5, 0.5)) * t + rng.uniform(0, 2 * np.pi)) + alpha = rng.uniform(10, 40) * np.sin( + 2 * np.pi * (10 + rng.uniform(-1, 1)) * t + rng.uniform(0, 2 * np.pi) + ) + theta = rng.uniform(5, 15) * np.sin( + 2 * np.pi * (6 + rng.uniform(-0.5, 0.5)) * t + rng.uniform(0, 2 * np.pi) + ) noise = rng.normal(0, 5, num_samples) # 60Hz line noise line_noise = 3 * np.sin(2 * np.pi * 60 * t) @@ -284,6 +331,7 @@ def create_demo_study(): if __name__ == "__main__": import uvicorn + create_demo_study() print("Starting EEG viewer at http://localhost:8000") uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/eegvis/viewer/components.py b/eegvis/viewer/components.py index a306869..049dbac 100644 --- a/eegvis/viewer/components.py +++ b/eegvis/viewer/components.py @@ -1,12 +1,30 @@ """ztml HTML components for the EEG viewer.""" from ztml import ( - Body, Button, Div, Fragment, H1, Head, Html, Label, Meta, - Option, P, Raw, RawCss, Script, Select, Span, Style, Title, Input, + Body, + Button, + Div, + Fragment, + H1, + Head, + Html, + Label, + Meta, + Option, + P, + Raw, + RawCss, + Script, + Select, + Span, + Style, + Title, + Input, ) from .session import ( - SENSITIVITY_PRESETS, PAGE_DURATION_PRESETS, + SENSITIVITY_PRESETS, + PAGE_DURATION_PRESETS, ViewerSession, ) @@ -32,7 +50,8 @@ def page_shell(*children): def _viewer_styles(): """Inline CSS for the viewer layout.""" - return Style(RawCss(""" + return Style( + RawCss(""" * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; } .viewer-root { max-width: 1400px; margin: 0 auto; padding: 8px; } @@ -69,10 +88,16 @@ def _viewer_styles(): position: absolute; top: 0; height: 100%; background: rgba(66, 133, 244, 0.3); border-left: 2px solid #4285f4; } - """)) + """) + ) -def viewer_page(session: ViewerSession, montage_names: list[str], svg_content: str, total_duration: float): +def viewer_page( + session: ViewerSession, + montage_names: list[str], + svg_content: str, + total_duration: float, +): """Build the full viewer page.""" signals = { "sensitivity": session.sensitivity, @@ -90,13 +115,11 @@ def viewer_page(session: ViewerSession, montage_names: list[str], svg_content: s _display_area(svg_content), _jump_bar(session, total_duration), _status_bar(session), - ).cls("viewer-root").attr( - "data-signals", f"{{{signals_str}}}" - ).attr( - "tabindex", "0" - ).attr( - "data-on:keydown", _keydown_handler(session.session_id) - ), + ) + .cls("viewer-root") + .attr("data-signals", f"{{{signals_str}}}") + .attr("tabindex", "0") + .attr("data-on:keydown", _keydown_handler(session.session_id)), ) @@ -107,60 +130,73 @@ def _toolbar(session: ViewerSession, montage_names: list[str]): Label("Montage:"), _montage_select(session, montage_names), _separator(), - # sensitivity Label("Sensitivity:"), _sensitivity_select(session), _separator(), - # page duration Label("Page:"), _page_duration_select(session), _separator(), - # navigation - Button("\u25c0\u25c0").title("Previous page (Left arrow)").attr( - "data-on:click", f"@get('/api/navigate?session_id={session.session_id}&action=page_back')" + Button("\u25c0\u25c0") + .title("Previous page (Left arrow)") + .attr( + "data-on:click", + f"@get('/api/navigate?session_id={session.session_id}&action=page_back')", ), - Button("\u25c0").title("Step back (h/j)").attr( - "data-on:click", f"@get('/api/navigate?session_id={session.session_id}&action=step_back')" + Button("\u25c0") + .title("Step back (h/j)") + .attr( + "data-on:click", + f"@get('/api/navigate?session_id={session.session_id}&action=step_back')", ), - Button("\u25b6").title("Step forward (k/l)").attr( - "data-on:click", f"@get('/api/navigate?session_id={session.session_id}&action=step_forward')" + Button("\u25b6") + .title("Step forward (k/l)") + .attr( + "data-on:click", + f"@get('/api/navigate?session_id={session.session_id}&action=step_forward')", ), - Button("\u25b6\u25b6").title("Next page (Right arrow)").attr( - "data-on:click", f"@get('/api/navigate?session_id={session.session_id}&action=page_forward')" + Button("\u25b6\u25b6") + .title("Next page (Right arrow)") + .attr( + "data-on:click", + f"@get('/api/navigate?session_id={session.session_id}&action=page_forward')", ), _separator(), - # jump to time Label("Go to:"), - Input().type("number").attr("data-bind", "currentTime").attr( - "min", "0" - ).attr( - "step", "1" - ).attr( - "data-on:change", f"@get('/api/navigate?session_id={session.session_id}&action=jump&time=' + $currentTime)" - ).style("width: 70px"), + Input() + .type("number") + .attr("data-bind", "currentTime") + .attr("min", "0") + .attr("step", "1") + .attr( + "data-on:change", + f"@get('/api/navigate?session_id={session.session_id}&action=jump&time=' + $currentTime)", + ) + .style("width: 70px"), Label("s"), _separator(), - # filters Label("HP:"), _filter_select( - "highpass", session.session_id, + "highpass", + session.session_id, [None, 0.1, 0.3, 0.5, 1.0, 1.5, 2.0, 5.0], session.highpass_freq, ), Label("LP:"), _filter_select( - "lowpass", session.session_id, + "lowpass", + session.session_id, [None, 15, 20, 30, 35, 40, 50, 70, 100], session.lowpass_freq, ), Label("Notch:"), _filter_select( - "notch", session.session_id, + "notch", + session.session_id, [None, 50, 60], session.notch_freq, ), @@ -175,11 +211,13 @@ def _montage_select(session: ViewerSession, montage_names: list[str]): if name == session.current_montage: opt = opt.selected(True) options.append(opt) - return Select(*options).attr( - "data-bind", "montage" - ).attr( - "data-on:change", - f"@get('/api/set_montage?session_id={session.session_id}&montage=' + $montage)", + return ( + Select(*options) + .attr("data-bind", "montage") + .attr( + "data-on:change", + f"@get('/api/set_montage?session_id={session.session_id}&montage=' + $montage)", + ) ) @@ -191,11 +229,13 @@ def _sensitivity_select(session: ViewerSession): if s == session.sensitivity: opt = opt.selected(True) options.append(opt) - return Select(*options).attr( - "data-bind", "sensitivity" - ).attr( - "data-on:change", - f"@get('/api/set_sensitivity?session_id={session.session_id}&sensitivity=' + $sensitivity)", + return ( + Select(*options) + .attr("data-bind", "sensitivity") + .attr( + "data-on:change", + f"@get('/api/set_sensitivity?session_id={session.session_id}&sensitivity=' + $sensitivity)", + ) ) @@ -208,15 +248,19 @@ def _page_duration_select(session: ViewerSession): if d == session.page_duration: opt = opt.selected(True) options.append(opt) - return Select(*options).attr( - "data-bind", "pageDuration" - ).attr( - "data-on:change", - f"@get('/api/set_page_duration?session_id={session.session_id}&duration=' + $pageDuration)", + return ( + Select(*options) + .attr("data-bind", "pageDuration") + .attr( + "data-on:change", + f"@get('/api/set_page_duration?session_id={session.session_id}&duration=' + $pageDuration)", + ) ) -def _filter_select(filter_type: str, session_id: str, values: list, current: float | None): +def _filter_select( + filter_type: str, session_id: str, values: list, current: float | None +): """Filter preset dropdown.""" options = [] for v in values: @@ -238,9 +282,13 @@ def _separator(): def _display_area(svg_content: str): """Main SVG display area.""" - return Div( - Raw(svg_content), - ).cls("display-area").id("eeg-display") + return ( + Div( + Raw(svg_content), + ) + .cls("display-area") + .id("eeg-display") + ) def _jump_bar(session: ViewerSession, total_duration: float): @@ -251,26 +299,37 @@ def _jump_bar(session: ViewerSession, total_duration: float): else: left_pct = 0 width_pct = 100 - return Div( - Div().cls("position-marker").style( - f"left: {left_pct:.1f}%; width: {width_pct:.1f}%" - ), - ).cls("jump-bar").id("jump-bar").attr( - "data-on:click", - f"@get('/api/navigate?session_id={session.session_id}&action=jump&time=' + " - f"Math.round(evt.offsetX / evt.target.offsetWidth * {total_duration}))", + return ( + Div( + Div() + .cls("position-marker") + .style(f"left: {left_pct:.1f}%; width: {width_pct:.1f}%"), + ) + .cls("jump-bar") + .id("jump-bar") + .attr( + "data-on:click", + f"@get('/api/navigate?session_id={session.session_id}&action=jump&time=' + " + f"Math.round(evt.offsetX / evt.target.offsetWidth * {total_duration}))", + ) ) def _status_bar(session: ViewerSession): """Status bar showing current state.""" time_str = f"{session.current_time:.1f}s - {session.current_time + session.page_duration:.1f}s" - return Div( - Span(f"Time: {time_str}"), - Span(f"Montage: {session.current_montage}"), - Span(f"Sensitivity: {session.sensitivity} \u00b5V/mm"), - Span(f"Filters: HP {session.highpass_freq or 'Off'} / LP {session.lowpass_freq or 'Off'} / Notch {session.notch_freq or 'Off'}"), - ).cls("status-bar").id("status-bar") + return ( + Div( + Span(f"Time: {time_str}"), + Span(f"Montage: {session.current_montage}"), + Span(f"Sensitivity: {session.sensitivity} \u00b5V/mm"), + Span( + f"Filters: HP {session.highpass_freq or 'Off'} / LP {session.lowpass_freq or 'Off'} / Notch {session.notch_freq or 'Off'}" + ), + ) + .cls("status-bar") + .id("status-bar") + ) def _keydown_handler(session_id: str): diff --git a/eegvis/viewer/session.py b/eegvis/viewer/session.py index eb710e7..845d6c2 100644 --- a/eegvis/viewer/session.py +++ b/eegvis/viewer/session.py @@ -81,7 +81,9 @@ def montage_state(self) -> MontageState: return self.montage_states[self.current_montage] def page_forward(self, max_time: float): - self.current_time = min(self.current_time + self.page_duration, max_time - self.page_duration) + self.current_time = min( + self.current_time + self.page_duration, max_time - self.page_duration + ) self.current_time = max(0.0, self.current_time) def page_backward(self): diff --git a/tests/test_stackplot_svg.py b/tests/test_stackplot_svg.py index b2057bd..ef6dd2f 100644 --- a/tests/test_stackplot_svg.py +++ b/tests/test_stackplot_svg.py @@ -87,9 +87,7 @@ def test_stackplot_svg_no_scalebar(): def test_stackplot_svg_scalebar_present(): """Default should include scalebar with unit text.""" signals = np.random.randn(5, 800) - svg_str = stackplot_svg.stackplot_svg( - signals, sample_frequency=256.0, seconds=3.0 - ) + svg_str = stackplot_svg.stackplot_svg(signals, sample_frequency=256.0, seconds=3.0) assert "\u00b5V" in svg_str # µV @@ -122,7 +120,9 @@ def test_save_svg_with_calibration(tmp_path): filepath = tmp_path / "test_calibration.svg" num_samples = 800 signals = np.random.randn(5, num_samples) * 30.0 - signals[-1, :] = make_calibration_signal(num_samples, levels=[0, 100, -100, 100, -100]) + signals[-1, :] = make_calibration_signal( + num_samples, levels=[0, 100, -100, 100, -100] + ) labels = ["Ch1", "Ch2", "Ch3", "Ch4", "Cal 100\u00b5V"] stackplot_svg.save_svg( @@ -184,8 +184,8 @@ def test_small_2channel_svg(): n = 100 t = np.arange(n) / fs signals = np.zeros((2, n)) - signals[0, :] = 50.0 * np.sin(2 * np.pi * 3 * t) # 3 Hz sine - signals[1, :] = 30.0 * np.sin(2 * np.pi * 10 * t) # 10 Hz sine + signals[0, :] = 50.0 * np.sin(2 * np.pi * 3 * t) # 3 Hz sine + signals[1, :] = 30.0 * np.sin(2 * np.pi * 10 * t) # 10 Hz sine stackplot_svg.save_svg( "test_small_2ch.svg", @@ -218,7 +218,9 @@ def test_downsample_noop_when_already_low(): def test_max_samples_per_channel_limits_polyline_points(): """max_samples_per_channel should produce fewer points in SVG.""" signals = np.random.randn(2, 5000) # high sample count - svg_full = stackplot_svg.stackplot_svg(signals, sample_frequency=1000.0, seconds=5.0) + svg_full = stackplot_svg.stackplot_svg( + signals, sample_frequency=1000.0, seconds=5.0 + ) svg_ds = stackplot_svg.stackplot_svg( signals, sample_frequency=1000.0, seconds=5.0, max_samples_per_channel=500 ) @@ -232,8 +234,8 @@ def test_bandpass_filter_attenuates_out_of_band(): n = int(fs * 4) # 4 seconds for filter settling t = np.arange(n) / fs signals = np.zeros((2, n)) - signals[0, :] = np.sin(2 * np.pi * 1.0 * t) # 1 Hz — below band - signals[1, :] = np.sin(2 * np.pi * 100.0 * t) # 100 Hz — above band + signals[0, :] = np.sin(2 * np.pi * 1.0 * t) # 1 Hz — below band + signals[1, :] = np.sin(2 * np.pi * 100.0 * t) # 100 Hz — above band filtered = stackplot_svg.bandpass_filter(signals, fs, low_freq=5.0, high_freq=30.0) @@ -281,7 +283,8 @@ def test_eeg_to_svg_basic(): signals = np.random.randn(4, n) * 50.0 svg_str = stackplot_svg.eeg_to_svg( - signals, fs, + signals, + fs, ylabels=["Ch1", "Ch2", "Ch3", "Ch4"], seconds=10.0, ) @@ -296,9 +299,25 @@ def test_eeg_to_svg_with_montage(): from eegvis.montageview import DoubleBananaMontageView rec_labels = [ - "Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4", - "O1", "O2", "F7", "F8", "T3", "T4", "T5", "T6", - "Fz", "Cz", "Pz", + "Fp1", + "Fp2", + "F3", + "F4", + "C3", + "C4", + "P3", + "P4", + "O1", + "O2", + "F7", + "F8", + "T3", + "T4", + "T5", + "T6", + "Fz", + "Cz", + "Pz", ] montage = DoubleBananaMontageView(rec_labels) fs = 256.0 @@ -319,7 +338,11 @@ def test_eeg_to_svg_no_filter(): signals = np.random.randn(2, n) * 30.0 svg_str = stackplot_svg.eeg_to_svg( - signals, fs, low_freq=None, high_freq=None, seconds=5.0, + signals, + fs, + low_freq=None, + high_freq=None, + seconds=5.0, ) root = ET.fromstring(svg_str) assert root.tag == f"{{{stackplot_svg.SVG_NS}}}svg" @@ -332,8 +355,10 @@ def test_eeg_to_svg_with_notch_and_downsample(): signals = np.random.randn(3, n) * 50.0 svg_str = stackplot_svg.eeg_to_svg( - signals, fs, - low_freq=1.0, high_freq=70.0, + signals, + fs, + low_freq=1.0, + high_freq=70.0, notch_freq=60.0, target_frequency=256.0, max_samples_per_channel=1000, @@ -352,8 +377,11 @@ def test_save_eeg_svg(tmp_path): signals = np.random.randn(3, n) * 50.0 stackplot_svg.save_eeg_svg( - str(filepath), signals, fs, - low_freq=1.0, high_freq=70.0, + str(filepath), + signals, + fs, + low_freq=1.0, + high_freq=70.0, seconds=5.0, ) assert filepath.exists() @@ -363,6 +391,7 @@ def test_save_eeg_svg(tmp_path): # --- Phase 1: SVG structure tests for interactive viewer support --- + def test_channel_has_translate_transform(): """Each channel should have a translate transform for baseline offset.""" signals = np.random.randn(3, 300) @@ -453,9 +482,7 @@ def test_per_channel_yscale(): def test_scalar_yscale_backward_compatible(): """Scalar yscale should still produce valid SVG with uniform scale factors.""" signals = np.random.randn(3, 300) - svg_str = stackplot_svg.stackplot_svg( - signals, sample_frequency=256.0, yscale=2.0 - ) + svg_str = stackplot_svg.stackplot_svg(signals, sample_frequency=256.0, yscale=2.0) root = ET.fromstring(svg_str) ns = {"svg": stackplot_svg.SVG_NS} polylines = root.findall(".//svg:polyline", ns) From 9ff04afd04e1f44ad950e72704ed04a102899b05 Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Sun, 10 May 2026 11:18:51 -0700 Subject: [PATCH 08/17] update CLAUDE.md --- CLAUDE.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f5b73a9..70cb8a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview eegvis is a Python library for visualizing EEG (electroencephalogram) data with -multiple backends: matplotlib (static), SVG, bokeh (interactive), and panel (dashboards). It targets clinical EEG workflows with montage-based channel derivations. +multiple backends: matplotlib (static), **pure SVG** (clinical-style stacked +traces, no matplotlib dependency), bokeh (interactive), and panel (dashboards). +It targets clinical EEG workflows with montage-based channel derivations. + +A growing focus of the project is **SVG output** for clinical-style review: +both standalone files for reports/printing and a hypermedia browser-based +viewer built on FastAPI + datastar + ztml. ## Build & Development @@ -18,11 +24,15 @@ pip install -e ".[eeghdf]" pip install -e ".[pyedflib]" # pyedflib must be <=0.1.22 (sample_frequency bug in later versions) ``` +Core dependencies now include `fastapi`, `datastar-py>=0.8.0`, and `ztml>=0.2.4` +to support the hypermedia viewer (see `eegvis/viewer/`). + ## Testing ```bash # Run tests with pytest pytest tests/ +pytest tests/test_stackplot_svg.py # SVG backend tests # Run via tox (multiple Python/matplotlib versions) tox @@ -32,12 +42,84 @@ tox -e py37-mpl3.2 # tests against matplotlib 3.2 (Colab compatibility) ## Architecture -### Visualization Backends (three parallel implementations) +### Visualization Backends (parallel implementations) - **`stacklineplot.py`** — Matplotlib backend using LineCollection with AffineDeltaTransform (backported from mpl 3.3 for 3.2 compatibility). Static/publication output. +- **`stackplot_svg.py`** — Pure SVG backend (no matplotlib). Generates standalone SVG strings/files using `xml.etree.ElementTree`. Native y-down axis gives "negative up" clinical convention for free. - **`stackplot_bokeh.py`** — Bokeh backend with ipywidgets integration and `push_notebook()` for interactive Jupyter use. - **`eegpanel.py`** / **`eegbokeh.py`** — Experimental Panel and pure-Bokeh browser implementations. +### SVG Backend (`stackplot_svg.py`) + +The SVG backend is structured as a pipeline of pure functions: + +- **`stackplot_svg(signals, sample_frequency, ...)`** — Core renderer. Returns + an SVG string. Layout is in millimeter units via `viewBox`. Each channel is a + `` containing a `` and a `` + whose `points` are time-in-SVG-units paired with raw data values; per-channel + `scale(1, y_scale_factor)` transforms map data units to SVG y. Strokes use + `vector-effect="non-scaling-stroke"` so line weight stays constant under + zoom. Includes vertical time grid, scale bar (µV by default), border, + channel labels, and an empty `` layer for downstream + viewer overlays. +- **`save_svg(filepath, ...)`** — Writes the SVG string to disk. +- **`show_montage_svg` / `save_montage_svg`** — Convenience wrappers that + apply a `MontageView.V.data` matrix multiply before rendering and use + `montage.montage_labels` as channel labels. +- **`eeg_to_svg(signals, sample_frequency, montage=, low_freq=, high_freq=, notch_freq=, target_frequency=, max_samples_per_channel=, ...)`** + — End-to-end pipeline: downsample → montage derivation → bandpass → notch → render. +- **`save_eeg_svg(filepath, ...)`** — File-output sibling of `eeg_to_svg`. + +Supporting helpers in the same module: + +- **`downsample(signals, fs, target_fs)`** — `scipy.signal.decimate`-based, returns `(signals, new_fs)`. +- **`bandpass_filter` / `notch_filter`** — Thin wrappers over `eegml_signal.filters` (FIR firwin highpass/lowpass; IIR notch). +- **`_compute_channel_offsets`** — Computes per-channel vertical offsets in data space. Two modes: absolute (sensitivity in data_units/mm × plot height) and auto (`(max - min) * 0.7`). + +SVG output carries data on the root and on each channel group (`data-sample-frequency`, +`data-start-time`, `data-seconds`, `data-num-channels`, plus per-channel +`data-baseline`, `data-channel-name`, `data-channel-index`, `data-yscale`). +These attributes are intended to be consumed by the viewer's client-side code +and by annotation tooling. + +`max_samples_per_channel` is the main lever for keeping SVG file size sane on +high-sample-rate recordings — it triggers a one-shot decimation tuned so each +polyline has at most that many points. + +### Hypermedia Clinical Viewer (`eegvis/viewer/`) + +A FastAPI app that renders SVG pages of EEG and streams updates via datastar +Server-Sent Events. The server holds session state and re-renders SVG on each +navigation/filter change — clients are mostly passive consumers of HTML/SVG +patches. + +- **`viewer/app.py`** — FastAPI routes. `GET /` serves the initial page; + `/api/navigate`, `/api/keydown`, `/api/set_montage`, `/api/set_sensitivity`, + `/api/set_page_duration`, `/api/set_filter` return `DatastarResponse` SSE + streams that patch `#eeg-display`, `#jump-bar`, and `#status-bar`. Studies + are registered via `load_study(study_id, signals, sample_frequency, channel_labels, montage_names)`. + `_render_page_svg` slices the current time window, applies session filters, + per-channel gains from `MontageState`, and calls `stackplot_svg`. +- **`viewer/session.py`** — `ViewerSession`, `MontageState`, and `SessionStore` + dataclasses. Tracks current time, page duration, montage, per-channel gain/lock/visibility, + filter cutoffs, and global sensitivity (µV/mm). Provides + `SENSITIVITY_PRESETS` and `PAGE_DURATION_PRESETS` lists matching common + clinical defaults. +- **`viewer/components.py`** — ztml HTML components for the page shell, toolbar + (montage / sensitivity / page-duration / nav / filter selects), display + area, jump bar, and status bar. Uses datastar `data-on:*` and `data-bind` + attributes; loads the datastar runtime from the jsDelivr CDN. + +Run the demo with synthetic data: + +```bash +uv run python -m eegvis.viewer.app # one-shot demo on :8000 +uv run uvicorn eegvis.viewer.app:app --reload +``` + +`create_demo_study()` synthesizes a 19-channel × 2-minute mix of alpha, theta, +noise, and 60 Hz line noise so the viewer is exercisable without real data. + ### Montage System - **`montageview.py`** — Defines clinical EEG montages (Double Banana, TCP, Laplacian, etc.) as linear transformation matrices using xarray DataArrays over OrderedDicts of channels. @@ -61,5 +143,9 @@ tox -e py37-mpl3.2 # tests against matplotlib 3.2 (Colab compatibility) - Signal data is stored as numpy arrays shaped (channels × samples). - Montage derivations are expressed as xarray matrix operations over ordered channel dictionaries. +- Clinical "negative up" display: in the SVG backend this falls out of SVG's + native y-down axis — raw data values map directly without sign flip. +- SVG layout coordinates are in millimeters via `viewBox`; CSS scales the SVG + to container width while preserving aspect ratio. - Python 3.7+ required (f-strings). Some files retain `__future__` imports for historical compatibility. - matplotlib >=3.2 is supported via backported `AffineDeltaTransform`. From bb7c37433fc7257f544f9941aa8ea17642fbe0e0 Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Sun, 10 May 2026 20:33:44 -0700 Subject: [PATCH 09/17] adding theming/style to svg at all 3 levels of interaction --- eegvis/stackplot_svg.py | 437 ++++++++++++++++++++++-------------- eegvis/viewer/components.py | 23 +- tests/test_stackplot_svg.py | 193 +++++++++++++++- 3 files changed, 481 insertions(+), 172 deletions(-) diff --git a/eegvis/stackplot_svg.py b/eegvis/stackplot_svg.py index 2a8ca7a..9f929a1 100644 --- a/eegvis/stackplot_svg.py +++ b/eegvis/stackplot_svg.py @@ -10,19 +10,114 @@ deflections appear as upward movements on screen. """ +from dataclasses import dataclass, field import numpy as np import xml.etree.ElementTree as ET SVG_NS = "http://www.w3.org/2000/svg" -# default styling -DEFAULT_TRACE_COLOR = "black" -DEFAULT_TRACE_WIDTH = "0.5" -DEFAULT_FONT_FAMILY = "sans-serif" -DEFAULT_FONT_SIZE = 3.5 # in SVG user units (mm in viewBox coordinates) -DEFAULT_GRID_COLOR = "#cccccc" -DEFAULT_GRID_WIDTH = "0.3" + +@dataclass +class Theme: + """Styling configuration for SVG EEG output. + + All size values are in SVG user units (mm when viewBox matches width_mm/height_mm). + """ + + # Fonts + font_family: str = "sans-serif" + font_size: float = 3.5 + label_font_family: str = "sans-serif" + label_font_size: float = 3.5 + label_color: str = "black" + time_label_font_size: float = 3.5 + time_label_color: str = "black" + scalebar_font_family: str = "sans-serif" + scalebar_font_size: float = 3.5 + scalebar_color: str = "black" + scalebar_bold: bool = False + + # Traces + trace_colors: list = field(default_factory=lambda: ["black"]) + trace_width: float = 0.5 + + # Grid + major_grid_color: str = "#cccccc" + major_grid_width: float = 0.3 + minor_grid_color: str = "#bfbfbf" + minor_grid_width: float = 0.3 + minor_grid_dash: str = "4" + + # Scale bar + scalebar_line_color: str = "black" + scalebar_line_width: float = 0.3 + scalebar_bg_color: str = "none" + scalebar_bg_opacity: float = 0.5 + + # Misc + border_color: str = "#999999" + border_width: float = 0.2 + background_color: str = "white" + + +DEFAULT_THEME = Theme() + +STRATUS_THEME = Theme( + font_family="Segoe UI, sans-serif", + font_size=3.5, + label_font_family="Segoe UI, sans-serif", + label_font_size=4.0, + label_color="#838487", + time_label_font_size=3.8, + time_label_color="#000000", + scalebar_font_family="Segoe UI, sans-serif", + scalebar_font_size=3.8, + scalebar_color="#000000", + scalebar_bold=True, + trace_colors=["#00007f", "#000000", "#7f0000"], + trace_width=0.5, + major_grid_color="#808080", + major_grid_width=0.5, + minor_grid_color="#bfbfbf", + minor_grid_width=0.3, + minor_grid_dash="4", + scalebar_line_color="#000000", + scalebar_line_width=0.5, + scalebar_bg_color="#dfdfdf", + scalebar_bg_opacity=0.5, + border_color="#808080", + border_width=0.3, + background_color="white", +) + +PUBLICATION_THEME = Theme( + font_family="DejaVu Sans, Arial, sans-serif", + font_size=3.0, + label_font_family="DejaVu Sans, Arial, sans-serif", + label_font_size=3.0, + label_color="#333333", + time_label_font_size=3.0, + time_label_color="#222222", + scalebar_font_family="DejaVu Sans, Arial, sans-serif", + scalebar_font_size=3.0, + scalebar_color="#222222", + scalebar_bold=False, + trace_colors=["#222222"], + trace_width=0.4, + major_grid_color="#dddddd", + major_grid_width=0.2, + minor_grid_color="#eeeeee", + minor_grid_width=0.15, + minor_grid_dash="2", + scalebar_line_color="#333333", + scalebar_line_width=0.25, + scalebar_bg_color="none", + scalebar_bg_opacity=0.0, + border_color="#bbbbbb", + border_width=0.15, + background_color="white", +) def downsample(signals, sample_frequency, target_frequency): @@ -73,13 +168,12 @@ def bandpass_filter(signals, sample_frequency, low_freq=1.0, high_freq=70.0): result = signals.copy() num_samples = signals.shape[1] - # filtfilt needs 3*numtaps < num_samples for padding max_taps = num_samples // 3 - 1 if low_freq is not None: numtaps = min(max(int(2 * sample_frequency), 3), max_taps) if numtaps % 2 == 0: - numtaps += 1 # highpass firwin needs odd numtaps + numtaps += 1 hp = esfilters.fir_highpass_firwin_ff(sample_frequency, low_freq, numtaps) for ch in range(result.shape[0]): result[ch] = hp(result[ch]) @@ -141,16 +235,33 @@ def _compute_channel_offsets(data, num_channels, sensitivity=None, height=None): """ ch_indices = np.arange(num_channels, dtype=float) if sensitivity is not None and height is not None: - # absolute sensitivity mode: distribute channels evenly across height dr = sensitivity * height / num_channels ticklocs = ch_indices * dr + dr / 2.0 else: - # auto mode: space based on data range dr = (data.max() - data.min()) * 0.7 ticklocs = ch_indices * dr return ticklocs, dr +def _build_style_element(svg, theme): + """Create the ")) diff --git a/eegvis/utils/laplacian.py b/eegvis/utils/laplacian.py index 0cf0574..2676d04 100644 --- a/eegvis/utils/laplacian.py +++ b/eegvis/utils/laplacian.py @@ -1,116 +1,285 @@ -montage = \ -OrderedDict([('Template', OrderedDict([('@Name', 'Laplacian'), - ('Defn', OrderedDict([('@ClassName', 'Montage'), - ('Channel', [OrderedDict([('@Name', 'F7-aF7'), - ('@Definition', 'F7-aF7'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'T3-aT3'), - ('@Definition', 'T3-aT3'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'T5-aT5'), - ('@Definition', 'T5-aT5'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'O1-aO1'), - ('@Definition', 'O1-aO1'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'F3-aF3'), - ('@Definition', 'F3-aF3'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'C3-aC3'), - ('@Definition', 'C3-aC3'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'P3-aP3'), - ('@Definition', 'P3-aP3'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'CZ-aCz'), - ('@Definition', 'CZ-aCz'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'F4-aF4'), - ('@Definition', 'F4-aF4'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'C4-aC4'), - ('@Definition', 'C4-aC4'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'P4-aP4'), - ('@Definition', 'P4-aP4'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'F8-aF8'), - ('@Definition', 'F8-aF8'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'T4-aT4'), - ('@Definition', 'T4-aT4'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'T6-aT6'), - ('@Definition', 'T6-aT6'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')]), - OrderedDict([('@Name', 'O2-aO2'), - ('@Definition', 'O2-aO2'), - ('@MasterControl', '1'), - ('@PenColor', '0'), - ('@OverlapPrevious', '0')])]), - ('AvgRef', [OrderedDict([('@Name', 'aF7'), - ('@Definition', 'FP1+F3+C3+T3')]), - OrderedDict([('@Name', 'aT3'), - ('@Definition', 'F7+C3+T5')]), - OrderedDict([('@Name', 'aT5'), - ('@Definition', 'T3+C3+P3+O1')]), - OrderedDict([('@Name', 'aF3'), - ('@Definition', 'F7+C3+FZ+FP1')]), - OrderedDict([('@Name', 'aC3'), - ('@Definition', 'T3+F3+CZ+P3')]), - OrderedDict([('@Name', 'aP3'), - ('@Definition', 'C3+PZ+O1+T5')]), - OrderedDict([('@Name', 'aFpz'), - ('@Definition', 'FP1+FZ+FP2')]), - OrderedDict([('@Name', 'aCz'), - ('@Definition', 'FZ+C4+PZ+C3')]), - OrderedDict([('@Name', 'aOz'), - ('@Definition', 'O1+PZ+O2')]), - OrderedDict([('@Name', 'aF4'), - ('@Definition', 'FP2+F8+C4+FZ')]), - OrderedDict([('@Name', 'aC4'), - ('@Definition', 'F4+T4+P4+CZ')]), - OrderedDict([('@Name', 'aP4'), - ('@Definition', 'C4+T6+O2+PZ')]), - OrderedDict([('@Name', 'aF8'), - ('@Definition', 'FP2+F4+T4')]), - OrderedDict([('@Name', 'aT4'), - ('@Definition', 'F8+C4+T6')]), - OrderedDict([('@Name', 'aT6'), - ('@Definition', 'T4+P4+O2')]), - OrderedDict([('@Name', 'Av17'), - ('@Definition', 'F7+F3+FZ+F4+F8+T3+C3+CZ+C4+T4+T5+P3+PZ+P4+T6+O1+O2')]), - OrderedDict([('@Name', 'Av12'), - ('@Definition', 'F3+F4+T3+C3+C4+T4+T5+P3+P4+T6+O1+O2')]), - OrderedDict([('@Name', 'aO1'), - ('@Definition', 'PZ+P3+T5')]), - OrderedDict([('@Name', 'aO2'), - ('@Definition', 'T6+P4+PZ')])])]))]))]) +montage = OrderedDict( + [ + ( + "Template", + OrderedDict( + [ + ("@Name", "Laplacian"), + ( + "Defn", + OrderedDict( + [ + ("@ClassName", "Montage"), + ( + "Channel", + [ + OrderedDict( + [ + ("@Name", "F7-aF7"), + ("@Definition", "F7-aF7"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "T3-aT3"), + ("@Definition", "T3-aT3"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "T5-aT5"), + ("@Definition", "T5-aT5"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "O1-aO1"), + ("@Definition", "O1-aO1"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "F3-aF3"), + ("@Definition", "F3-aF3"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "C3-aC3"), + ("@Definition", "C3-aC3"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "P3-aP3"), + ("@Definition", "P3-aP3"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "CZ-aCz"), + ("@Definition", "CZ-aCz"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "F4-aF4"), + ("@Definition", "F4-aF4"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "C4-aC4"), + ("@Definition", "C4-aC4"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "P4-aP4"), + ("@Definition", "P4-aP4"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "F8-aF8"), + ("@Definition", "F8-aF8"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "T4-aT4"), + ("@Definition", "T4-aT4"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "T6-aT6"), + ("@Definition", "T6-aT6"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + OrderedDict( + [ + ("@Name", "O2-aO2"), + ("@Definition", "O2-aO2"), + ("@MasterControl", "1"), + ("@PenColor", "0"), + ("@OverlapPrevious", "0"), + ] + ), + ], + ), + ( + "AvgRef", + [ + OrderedDict( + [ + ("@Name", "aF7"), + ("@Definition", "FP1+F3+C3+T3"), + ] + ), + OrderedDict( + [ + ("@Name", "aT3"), + ("@Definition", "F7+C3+T5"), + ] + ), + OrderedDict( + [ + ("@Name", "aT5"), + ("@Definition", "T3+C3+P3+O1"), + ] + ), + OrderedDict( + [ + ("@Name", "aF3"), + ("@Definition", "F7+C3+FZ+FP1"), + ] + ), + OrderedDict( + [ + ("@Name", "aC3"), + ("@Definition", "T3+F3+CZ+P3"), + ] + ), + OrderedDict( + [ + ("@Name", "aP3"), + ("@Definition", "C3+PZ+O1+T5"), + ] + ), + OrderedDict( + [ + ("@Name", "aFpz"), + ("@Definition", "FP1+FZ+FP2"), + ] + ), + OrderedDict( + [ + ("@Name", "aCz"), + ("@Definition", "FZ+C4+PZ+C3"), + ] + ), + OrderedDict( + [ + ("@Name", "aOz"), + ("@Definition", "O1+PZ+O2"), + ] + ), + OrderedDict( + [ + ("@Name", "aF4"), + ("@Definition", "FP2+F8+C4+FZ"), + ] + ), + OrderedDict( + [ + ("@Name", "aC4"), + ("@Definition", "F4+T4+P4+CZ"), + ] + ), + OrderedDict( + [ + ("@Name", "aP4"), + ("@Definition", "C4+T6+O2+PZ"), + ] + ), + OrderedDict( + [ + ("@Name", "aF8"), + ("@Definition", "FP2+F4+T4"), + ] + ), + OrderedDict( + [ + ("@Name", "aT4"), + ("@Definition", "F8+C4+T6"), + ] + ), + OrderedDict( + [ + ("@Name", "aT6"), + ("@Definition", "T4+P4+O2"), + ] + ), + OrderedDict( + [ + ("@Name", "Av17"), + ( + "@Definition", + "F7+F3+FZ+F4+F8+T3+C3+CZ+C4+T4+T5+P3+PZ+P4+T6+O1+O2", + ), + ] + ), + OrderedDict( + [ + ("@Name", "Av12"), + ( + "@Definition", + "F3+F4+T3+C3+C4+T4+T5+P3+P4+T6+O1+O2", + ), + ] + ), + OrderedDict( + [ + ("@Name", "aO1"), + ("@Definition", "PZ+P3+T5"), + ] + ), + OrderedDict( + [ + ("@Name", "aO2"), + ("@Definition", "T6+P4+PZ"), + ] + ), + ], + ), + ] + ), + ), + ] + ), + ) + ] +) diff --git a/eegvis/viewer/components.py b/eegvis/viewer/components.py index e634eb7..d499bc7 100644 --- a/eegvis/viewer/components.py +++ b/eegvis/viewer/components.py @@ -1,5 +1,6 @@ # %% """ztml HTML components for the EEG viewer.""" + # from datastar_py import attribute_generator as dsattr # import ServerSentEventGenerator as SSE # # %% # # little experiment @@ -35,8 +36,9 @@ Title, Input, ) + # %% -#Div('testdiv').data("on:click","expression").__html__() +# Div('testdiv').data("on:click","expression").__html__() # %% from .session import ( SENSITIVITY_PRESETS, @@ -135,8 +137,8 @@ def viewer_page( .cls("viewer-root") .attr("data-signals", f"{{{signals_str}}}") .attr("tabindex", "0") - .data('on:keydown', _keydown_handler(session.session_id)), - #.attr("data-on:keydown", _keydown_handler(session.session_id)), + .data("on:keydown", _keydown_handler(session.session_id)), + # .attr("data-on:keydown", _keydown_handler(session.session_id)), ) diff --git a/experiments/bokeh_ext_ts_or_es.py b/experiments/bokeh_ext_ts_or_es.py index 5c19eef..df18ee8 100644 --- a/experiments/bokeh_ext_ts_or_es.py +++ b/experiments/bokeh_ext_ts_or_es.py @@ -58,7 +58,6 @@ class Custom(LayoutDOM): - __implementation__ = TypeScript(CODE) text = String(default="Custom text") diff --git a/experiments/eeg_app1.py b/experiments/eeg_app1.py index 76b898e..e2d2aca 100644 --- a/experiments/eeg_app1.py +++ b/experiments/eeg_app1.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -* # eeg_app1.py first try -""" -""" +""" """ + from __future__ import print_function, division, unicode_literals import pandas as pd import numpy as np @@ -28,10 +28,10 @@ class EEGBrowser: """ - work in bokeh app + work in bokeh app given an hdf @signal array-like object allow: - - scrolling + - scrolling - goto ? filtering ? montaging (linear combinations) @@ -125,7 +125,7 @@ def stackplot_t( ylabels=None, yscale=1.0, topdown=True, - **kwargs + **kwargs, ): """ will plot a stack of traces one above the other assuming @@ -161,9 +161,9 @@ def stackplot_t( ticklocs = [] if not "width" in kwargs: - kwargs[ - "width" - ] = 950 # a default width that is wider but can just fit in jupyter + kwargs["width"] = ( + 950 # a default width that is wider but can just fit in jupyter + ) fig = bplt.figure( tools="pan,box_zoom,reset,resize,previewsave,lasso_select", **kwargs ) # subclass of Plot that simplifies plot creation @@ -238,11 +238,10 @@ def stackplot( start_time=start_time, ylabels=ylabels, yscale=yscale, - **kwargs + **kwargs, ) def show_epoch_centered(self): - """ @signals array-like object with signals[ch_num, sample_num] @goto_sec where to go in the signal to show the feature diff --git a/experiments/eeg_app2.py b/experiments/eeg_app2.py index 6e0b267..04ad597 100644 --- a/experiments/eeg_app2.py +++ b/experiments/eeg_app2.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -* # eeg_app2.py first try -""" -""" +""" """ + from __future__ import print_function, division, unicode_literals import pandas as pd import numpy as np @@ -113,10 +113,10 @@ class KeyboardResponder(LayoutDOM): class EEGBrowser: """ - work in bokeh app + work in bokeh app given an hdf @signal array-like object allow: - - scrolling + - scrolling - goto ? filtering ? montaging (linear combinations) @@ -237,9 +237,9 @@ def stackplot_t( ticklocs = [] if not "width" in kwargs: - kwargs[ - "width" - ] = 950 # a default width that is wider but can just fit in jupyter + kwargs["width"] = ( + 950 # a default width that is wider but can just fit in jupyter + ) fig = bplt.figure( tools="pan,box_zoom,reset,previewsave,lasso_select", **kwargs ) # subclass of Plot that simplifies plot creation @@ -314,11 +314,10 @@ def stackplot( start_time=start_time, ylabels=ylabels, yscale=yscale, - **kwargs + **kwargs, ) def show_epoch_centered(self): - """ @signals array-like object with signals[ch_num, sample_num] @goto_sec where to go in the signal to show the feature diff --git a/experiments/eeg_app_add_jquery.py b/experiments/eeg_app_add_jquery.py index 5adb4b3..c7f015f 100644 --- a/experiments/eeg_app_add_jquery.py +++ b/experiments/eeg_app_add_jquery.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -* # eeg_app_add_jquery.py first try # at adding the jquery library to deal with keydown events -""" -""" +""" """ + from __future__ import print_function, division, unicode_literals import pandas as pd import numpy as np @@ -27,10 +27,10 @@ class EEGBrowser: """ - work in bokeh app + work in bokeh app given an hdf @signal array-like object allow: - - scrolling + - scrolling - goto ? filtering ? montaging (linear combinations) @@ -151,9 +151,9 @@ def stackplot_t( ticklocs = [] if not "width" in kwargs: - kwargs[ - "width" - ] = 950 # a default width that is wider but can just fit in jupyter + kwargs["width"] = ( + 950 # a default width that is wider but can just fit in jupyter + ) fig = bplt.figure( tools="pan,box_zoom,reset,resize,previewsave,lasso_select", **kwargs ) # subclass of Plot that simplifies plot creation @@ -228,11 +228,10 @@ def stackplot( start_time=start_time, ylabels=ylabels, yscale=yscale, - **kwargs + **kwargs, ) def show_epoch_centered(self): - """ @signals array-like object with signals[ch_num, sample_num] @goto_sec where to go in the signal to show the feature diff --git a/experiments/eeg_keymod1/main.py b/experiments/eeg_keymod1/main.py index fec9c1a..f48b063 100644 --- a/experiments/eeg_keymod1/main.py +++ b/experiments/eeg_keymod1/main.py @@ -14,6 +14,7 @@ import bokeh.models.widgets import bokeh.layouts as layouts # column, row, ?grid + # import bokeh.models.widgets as bmw # import bokeh.models.sources as bms from bokeh.models import FuncTickFormatter @@ -24,21 +25,24 @@ from bokeh.palettes import RdYlBu3 from bokeh.plotting import figure, curdoc -# stuff to define new widget +# stuff to define new widget from bokeh.models import LayoutDOM from bokeh.util.compiler import TypeScript -#from bokeh.core.properties import Int # String, Instance -import bokeh.core.properties as properties # import Int # String, Instance + +# from bokeh.core.properties import Int # String, Instance +import bokeh.core.properties as properties # import Int # String, Instance import eeghdf import eegvis.nb_eegview -ARCHIVEDIR = r'../../eeghdf/data/' -#EEGFILE = ARCHIVEDIR + 'spasms.eeghdf' -EEGFILE = ARCHIVEDIR + 'absence_epilepsy.eeghdf' +ARCHIVEDIR = r"../../eeghdf/data/" +# EEGFILE = ARCHIVEDIR + 'spasms.eeghdf' +EEGFILE = ARCHIVEDIR + "absence_epilepsy.eeghdf" hf = eeghdf.Eeghdf(EEGFILE) -eegbrow = eegvis.nb_eegview.EeghdfBrowser(hf, montage='double banana', start_seconds=1385, plot_width=1024, plot_height=700) +eegbrow = eegvis.nb_eegview.EeghdfBrowser( + hf, montage="double banana", start_seconds=1385, plot_width=1024, plot_height=700 +) eegbrow.show_for_bokeh_app() ## set up some synthetic data @@ -47,87 +51,99 @@ # y = np.sin(x) # source = bokeh.models.ColumnDataSource(data=dict(x=x, y=y)) + class KeyModResponder(LayoutDOM): """capture all aspects of keydown/up events""" - #__implementation__ = TypeScript(KEYBOARDRESPONDERCODE_TS) + + # __implementation__ = TypeScript(KEYBOARDRESPONDERCODE_TS) __implementation__ = "keymodresponder.ts" # can use # __css__ = '' # can use # __javascript__ = 'katex.min.js' # for additional javascript - # this should match with javascript/typescript implementation + # this should match with javascript/typescript implementation key = properties.String(default="") - keyCode = properties.Int(default=0) + keyCode = properties.Int(default=0) altKey = properties.Bool(default=False) ctrlKey = properties.Bool(default=False) metaKey = properties.Bool(default=False) shiftKey = properties.Bool(default=False) - + key_num_presses = properties.Int(default=0) - - keypress_callback = properties.Instance(bokeh.models.callbacks.Callback, - help=""" A callback to run in the browser whenever a key is pressed - """) + + keypress_callback = properties.Instance( + bokeh.models.callbacks.Callback, + help=""" A callback to run in the browser whenever a key is pressed + """, + ) # not sure how to do this to make it so could call # def __init__(self, parent=None): # by default will attach to top level document # """km = KeyModResonder(parent=containerdiv)""" # !! don't know how to implement - + + keyboard = KeyModResponder() -keyboard.css_classes = ['keyboard'] +keyboard.css_classes = ["keyboard"] callback_keyboard = bokeh.models.callbacks.CustomJS( - args=dict(keyboard=keyboard, code=""" + args=dict( + keyboard=keyboard, + code=""" console.log('in callback_keyboard') console.log('keyCode:', keyboard.keyCode) - """)) + """, + ) +) # keyboard.js_on_event('change:keyCode', callback_keyboard) # no luck -keyboard.js_on_change('keyCode', callback_keyboard) - - - - +keyboard.js_on_change("keyCode", callback_keyboard) -DOC = curdoc() # hold on to an instance of current doc in case need multithreads -SIZING_MODE = 'fixed' # 'scale_width' also an option, 'scale_both', 'scale_width', 'scale_height', 'stretch_both' +DOC = curdoc() # hold on to an instance of current doc in case need multithreads +SIZING_MODE = "fixed" # 'scale_width' also an option, 'scale_both', 'scale_width', 'scale_height', 'stretch_both' -#placeholder figure -#mainfig = figure(tools="previewsave", width=600, height=400) +# placeholder figure +# mainfig = figure(tools="previewsave", width=600, height=400) mainfig = eegbrow.fig -#desc = bokeh.models.Div(text=open(path.join(path.dirname(__file__), "description.html")).read(), width=800) +# desc = bokeh.models.Div(text=open(path.join(path.dirname(__file__), "description.html")).read(), width=800) desc = bokeh.models.Div(text="""Some placeholder text""") ### layout ### # there are unicode labels which would look better MVT_BWIDTH = 50 # note am setting button width as same as widget box (wbox50) to make one abut the next -bBackward10 = bokeh.models.widgets.Button(label='<<', width=MVT_BWIDTH) -bBackward1 = bokeh.models.widgets.Button(label='\u25C0', width=MVT_BWIDTH) # <- -bForward10 = bokeh.models.widgets.Button(label='>>', width=MVT_BWIDTH) -bForward1 = bokeh.models.widgets.Button(label='\u25B6', width=MVT_BWIDTH) # -> or '\u279F' +bBackward10 = bokeh.models.widgets.Button(label="<<", width=MVT_BWIDTH) +bBackward1 = bokeh.models.widgets.Button(label="\u25c0", width=MVT_BWIDTH) # <- +bForward10 = bokeh.models.widgets.Button(label=">>", width=MVT_BWIDTH) +bForward1 = bokeh.models.widgets.Button( + label="\u25b6", width=MVT_BWIDTH +) # -> or '\u279F' + def forward1(): eegbrow.loc_sec += 1 # print('keyCode: ', keyboard.keyCode) eegbrow.update() + def forward10(): eegbrow.loc_sec += 10 # print('keyCode: ', keyboard.keyCode) eegbrow.update() + def backward1(): eegbrow.loc_sec -= 1 # print('keyCode: ', keyboard.keyCode) eegbrow.update() + def backward10(): eegbrow.loc_sec -= 10 # print('keyCode: ', keyboard.keyCode) eegbrow.update() - + + bForward1.on_click(forward1) bBackward1.on_click(backward1) bForward10.on_click(forward10) @@ -141,7 +157,7 @@ def backward10(): # the CustomJS args dict maps string names to Bkeh models, any models on python side # will be avaiable in the javascript code string (@code) cb_obj is also available which represents -# the model triggering the callback +# the model triggering the callback callback_keydown = bokeh.models.callbacks.CustomJS( args=dict(keyboard=keyboard, bForward1=bForward1), code=""" @@ -155,70 +171,90 @@ def backward10(): // console.log('keyCode:', keyboard.keyCode) // console.log('cb_obj:', cb_obj) // keyboard.change.emit() // is this needed? - """) -#keyboard.keypress_callback = callback_keydown # this does not seem to work??? -keyboard.js_on_change('keyCode', callback_keydown) + """, +) +# keyboard.keypress_callback = callback_keydown # this does not seem to work??? +keyboard.js_on_change("keyCode", callback_keydown) - def keycallback_print(attr, old, new): - print('keycallback_print: ', 'keyCode:', keyboard.keyCode, 'key:"%s"' % keyboard.key, 'ctrl/shift/alt:', - keyboard.ctrlKey, keyboard.shiftKey, keyboard.altKey, attr, old, new) + print( + "keycallback_print: ", + "keyCode:", + keyboard.keyCode, + 'key:"%s"' % keyboard.key, + "ctrl/shift/alt:", + keyboard.ctrlKey, + keyboard.shiftKey, + keyboard.altKey, + attr, + old, + new, + ) + def keycallback(attr, old, new): # print('keycallback: ', attr, old, new, 'keyCode:', keyboard.keyCode) - keycallback_print(attr,old,new) - if keyboard.keyCode == 71: # KeyG + keycallback_print(attr, old, new) + if keyboard.keyCode == 71: # KeyG forward10() - if keyboard.keyCode == 70: # KeyF + if keyboard.keyCode == 70: # KeyF forward1() - if keyboard.keyCode == 65: # KeyA + if keyboard.keyCode == 65: # KeyA backward10() - if keyboard.keyCode == 68: # KeyD + if keyboard.keyCode == 68: # KeyD eegbrow.yscale /= 1.5 eegbrow.update() - if keyboard.keyCode == 38: # ArrowUp + if keyboard.keyCode == 38: # ArrowUp # increase gain eegbrow.yscale *= 1.5 eegbrow.update() - if keyboard.keyCode == 69: # key='e' - print('loc_sec:', eegbrow.loc_sec) + if keyboard.keyCode == 69: # key='e' + print("loc_sec:", eegbrow.loc_sec) eegbrow.yscale *= 1.5 eegbrow.update() return - if keyboard.keyCode == 83: # KeyS + if keyboard.keyCode == 83: # KeyS backward1() - if keyboard.keyCode == 39: # ArrowRight + if keyboard.keyCode == 39: # ArrowRight forward10() - if keyboard.keyCode == 37: # ArrowLeft + if keyboard.keyCode == 37: # ArrowLeft backward10() - - if keyboard.keyCode == 40: # ArrowDown + + if keyboard.keyCode == 40: # ArrowDown # decrease gain - eegbrow.yscale /= 1.5 + eegbrow.yscale /= 1.5 eegbrow.update() - -keyboard.on_change('key_num_presses',keycallback) +keyboard.on_change("key_num_presses", keycallback) # keyboard.on_change('keyCode',keycallback_print) - -bottomrowctrls = [bBackward10,bBackward1,bForward1, bForward10] -toprowctrls = [bokeh.models.widgets.Select(title='Montage',value='trace', options=['trace', 'db','tcp']), - bokeh.models.widgets.Select(title='Sensitivity',value='7uV/div', options=['1uV/div', '3uV/div','7uV/div','10uV/div']), - bokeh.models.widgets.Select(title='LF',value='0.3Hz', options=['None', '0.1Hz','0.3Hz','1Hz','5Hz']), - bokeh.models.widgets.Select(title='HF',value='70Hz', options=['None', '15Hz','30Hz','50Hz','70Hz']), +bottomrowctrls = [bBackward10, bBackward1, bForward1, bForward10] +toprowctrls = [ + bokeh.models.widgets.Select( + title="Montage", value="trace", options=["trace", "db", "tcp"] + ), + bokeh.models.widgets.Select( + title="Sensitivity", + value="7uV/div", + options=["1uV/div", "3uV/div", "7uV/div", "10uV/div"], + ), + bokeh.models.widgets.Select( + title="LF", value="0.3Hz", options=["None", "0.1Hz", "0.3Hz", "1Hz", "5Hz"] + ), + bokeh.models.widgets.Select( + title="HF", value="70Hz", options=["None", "15Hz", "30Hz", "50Hz", "70Hz"] + ), ] - -#for control in controls: -# control.on_change('value', lambda attr, old, new: update()) +# for control in controls: +# control.on_change('value', lambda attr, old, new: update()) -#inputs = widgetbox(*controls[:3], sizing_mode=SIZING_MODE) +# inputs = widgetbox(*controls[:3], sizing_mode=SIZING_MODE) wbox50 = functools.partial(layouts.widgetbox, sizing_mode=SIZING_MODE, width=MVT_BWIDTH) wbox20 = functools.partial(layouts.widgetbox, sizing_mode=SIZING_MODE, width=150) toprow = layouts.row(*map(wbox20, toprowctrls)) @@ -226,13 +262,16 @@ def keycallback(attr, old, new): print(toprow) bottomrow = layouts.row(*map(wbox50, bottomrowctrls)) -L = layouts.layout([ - [desc], - [toprow], - [mainfig], - [bottomrow], - [keyboard], - ], sizing_mode=SIZING_MODE) +L = layouts.layout( + [ + [desc], + [toprow], + [mainfig], + [bottomrow], + [keyboard], + ], + sizing_mode=SIZING_MODE, +) L.js_on_event DOC.add_root(L) diff --git a/experiments/eegapp3/main.py b/experiments/eegapp3/main.py index dafe05b..4bb4c86 100644 --- a/experiments/eegapp3/main.py +++ b/experiments/eegapp3/main.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -* # eeg_app2.py first try -""" -""" +""" """ + from __future__ import print_function, division, unicode_literals import functools import os.path as path @@ -16,6 +16,7 @@ import bokeh.models import bokeh.models.widgets import bokeh.layouts as layouts # column, row, ?grid + # import bokeh.models.widgets as bmw # import bokeh.models.sources as bms from bokeh.models import FuncTickFormatter @@ -26,53 +27,49 @@ from bokeh.palettes import RdYlBu3 from bokeh.plotting import figure, curdoc -# stuff to define new widget +# stuff to define new widget from bokeh.models import LayoutDOM from bokeh.util.compiler import TypeScript -from bokeh.core.properties import Int # String, Instance - -DOC = curdoc() # hold on to an instance of current doc in case need multithreads -SIZING_MODE = 'fixed' # 'scale_width' also an option, 'scale_both', 'scale_width', 'scale_height', 'stretch_both' - +from bokeh.core.properties import Int # String, Instance +DOC = curdoc() # hold on to an instance of current doc in case need multithreads +SIZING_MODE = "fixed" # 'scale_width' also an option, 'scale_both', 'scale_width', 'scale_height', 'stretch_both' -#placeholder figure -mainfig = figure(tools="pan,box_zoom,reset,resize,previewsave,lasso_select", - width=1200) +# placeholder figure +mainfig = figure(tools="pan,box_zoom,reset,resize,previewsave,lasso_select", width=1200) - -desc = bokeh.models.Div(text=open(path.join(path.dirname(__file__), "description.html")).read(), width=800) +desc = bokeh.models.Div( + text=open(path.join(path.dirname(__file__), "description.html")).read(), width=800 +) ### layout ### # -bBackward10 = bokeh.models.widgets.Button(label='<<') # ,width=1) -bBackward1 = bokeh.models.widgets.Button(label='<') -bForward10 = bokeh.models.widgets.Button(label='>>') -bForward1 = bokeh.models.widgets.Button(label='>') - -toprowctrls = [bBackward10,bBackward1,bForward1, bForward10] -bottomrowctrls = [bokeh.models.widgets.Select(title='montage',value='trace', options=['trace', 'db','tcp']), - bokeh.models.widgets.Button(label="spacer")] - -#for control in controls: +bBackward10 = bokeh.models.widgets.Button(label="<<") # ,width=1) +bBackward1 = bokeh.models.widgets.Button(label="<") +bForward10 = bokeh.models.widgets.Button(label=">>") +bForward1 = bokeh.models.widgets.Button(label=">") + +toprowctrls = [bBackward10, bBackward1, bForward1, bForward10] +bottomrowctrls = [ + bokeh.models.widgets.Select( + title="montage", value="trace", options=["trace", "db", "tcp"] + ), + bokeh.models.widgets.Button(label="spacer"), +] + +# for control in controls: # control.on_change('value', lambda attr, old, new: update()) - -#inputs = widgetbox(*controls[:3], sizing_mode=SIZING_MODE) +# inputs = widgetbox(*controls[:3], sizing_mode=SIZING_MODE) wbox = functools.partial(layouts.widgetbox, sizing_mode=SIZING_MODE) wbox20 = functools.partial(layouts.widgetbox, sizing_mode=SIZING_MODE) toprow = layouts.row(*map(wbox20, toprowctrls)) # toprow = layouts.row(wbox(layouts.row(children=toprowctrls))) print(toprow) bottomrow = layouts.row(*map(wbox, bottomrowctrls)) -L = layouts.layout([ - [desc], - [toprow], - [mainfig], - [bottomrow] - ], sizing_mode=SIZING_MODE) +L = layouts.layout([[desc], [toprow], [mainfig], [bottomrow]], sizing_mode=SIZING_MODE) DOC.add_root(L) diff --git a/experiments/eegapp4/main.py b/experiments/eegapp4/main.py index 9977c4a..25c700c 100644 --- a/experiments/eegapp4/main.py +++ b/experiments/eegapp4/main.py @@ -14,6 +14,7 @@ import bokeh.models.widgets import bokeh.layouts as layouts # column, row, ?grid + # import bokeh.models.widgets as bmw # import bokeh.models.sources as bms from bokeh.models import FuncTickFormatter @@ -24,21 +25,24 @@ from bokeh.palettes import RdYlBu3 from bokeh.plotting import figure, curdoc -# stuff to define new widget +# stuff to define new widget from bokeh.models import LayoutDOM from bokeh.util.compiler import TypeScript -#from bokeh.core.properties import Int # String, Instance -import bokeh.core.properties as properties # import Int # String, Instance + +# from bokeh.core.properties import Int # String, Instance +import bokeh.core.properties as properties # import Int # String, Instance import eeghdf import eegvis.nb_eegview -ARCHIVEDIR = r'../../eeghdf/data/' -#EEGFILE = ARCHIVEDIR + 'spasms.eeghdf' -EEGFILE = ARCHIVEDIR + 'absence_epilepsy.eeghdf' +ARCHIVEDIR = r"../../eeghdf/data/" +# EEGFILE = ARCHIVEDIR + 'spasms.eeghdf' +EEGFILE = ARCHIVEDIR + "absence_epilepsy.eeghdf" hf = eeghdf.Eeghdf(EEGFILE) -eegbrow = eegvis.nb_eegview.EeghdfBrowser(hf, montage='double banana', start_seconds=1385, plot_width=1024, plot_height=700) +eegbrow = eegvis.nb_eegview.EeghdfBrowser( + hf, montage="double banana", start_seconds=1385, plot_width=1024, plot_height=700 +) eegbrow.show_for_bokeh_app() ## set up some synthetic data @@ -47,7 +51,7 @@ # y = np.sin(x) # source = bokeh.models.ColumnDataSource(data=dict(x=x, y=y)) -## +## KEYBOARDRESPONDERCODE_TS = """ import {div, empty} from "core/dom" import * as p from "core/properties" @@ -128,32 +132,40 @@ """ + class KeyboardResponder(LayoutDOM): - #__implementation__ = TypeScript(KEYBOARDRESPONDERCODE_TS) + # __implementation__ = TypeScript(KEYBOARDRESPONDERCODE_TS) __implementation__ = "keyboardresponder.ts" # can use # __css__ = '' # can use # __javascript__ = 'katex.min.js' - keycode = properties.Int(default=0) # this should match with javascript + keycode = properties.Int(default=0) # this should match with javascript key_num_presses = properties.Int(default=0) - keypress_callback = properties.Instance(bokeh.models.callbacks.Callback, - help=""" A callback to run in the browser whenever a key is pressed - """) + keypress_callback = properties.Instance( + bokeh.models.callbacks.Callback, + help=""" A callback to run in the browser whenever a key is pressed + """, + ) + + keyboard = KeyboardResponder() -keyboard.css_classes = ['keyboard'] +keyboard.css_classes = ["keyboard"] callback_keyboard = bokeh.models.callbacks.CustomJS( - args=dict(keyboard=keyboard, code=""" + args=dict( + keyboard=keyboard, + code=""" console.log('in callback_keyboard') console.log('keycode:', keyboard.keycode) /* keyboard.change.emit() */ - """)) + """, + ) +) # keyboard.js_on_event('change:keycode', callback_keyboard) -keyboard.js_on_change('keycode', callback_keyboard) +keyboard.js_on_change("keycode", callback_keyboard) - # the CustomJS args dict maps string names to Bkeh models, any models on python side # will be avaiable in the javascript code string (@code) cb_obj is also available which represents -# the model triggering the callback +# the model triggering the callback callback_keydown = bokeh.models.callbacks.CustomJS( args=dict(keyboard=keyboard), code=""" @@ -161,52 +173,59 @@ class KeyboardResponder(LayoutDOM): // console.log('keycode:', keyboard.keycode) // console.log('cb_obj:', cb_obj) // keyboard.change.emit() // is this needed? - """) + """, +) keyboard.keypress_callback = callback_keydown -DOC = curdoc() # hold on to an instance of current doc in case need multithreads +DOC = curdoc() # hold on to an instance of current doc in case need multithreads -SIZING_MODE = 'fixed' # 'scale_width' also an option, 'scale_both', 'scale_width', 'scale_height', 'stretch_both' +SIZING_MODE = "fixed" # 'scale_width' also an option, 'scale_both', 'scale_width', 'scale_height', 'stretch_both' - -#placeholder figure -#mainfig = figure(tools="previewsave", width=600, height=400) +# placeholder figure +# mainfig = figure(tools="previewsave", width=600, height=400) mainfig = eegbrow.fig -#desc = bokeh.models.Div(text=open(path.join(path.dirname(__file__), "description.html")).read(), width=800) +# desc = bokeh.models.Div(text=open(path.join(path.dirname(__file__), "description.html")).read(), width=800) desc = bokeh.models.Div(text="""Some placeholder text""") ### layout ### # there are unicode labels which would look better MVT_BWIDTH = 50 # note am setting button width as same as widget box (wbox50) to make one abut the next -bBackward10 = bokeh.models.widgets.Button(label='<<', width=MVT_BWIDTH) -bBackward1 = bokeh.models.widgets.Button(label='\u25C0', width=MVT_BWIDTH) # <- -bForward10 = bokeh.models.widgets.Button(label='>>', width=MVT_BWIDTH) -bForward1 = bokeh.models.widgets.Button(label='\u25B6', width=MVT_BWIDTH) # -> or '\u279F' +bBackward10 = bokeh.models.widgets.Button(label="<<", width=MVT_BWIDTH) +bBackward1 = bokeh.models.widgets.Button(label="\u25c0", width=MVT_BWIDTH) # <- +bForward10 = bokeh.models.widgets.Button(label=">>", width=MVT_BWIDTH) +bForward1 = bokeh.models.widgets.Button( + label="\u25b6", width=MVT_BWIDTH +) # -> or '\u279F' + def forward1(): eegbrow.loc_sec += 1 # print('keycode: ', keyboard.keycode) eegbrow.update() + def forward10(): eegbrow.loc_sec += 10 # print('keycode: ', keyboard.keycode) eegbrow.update() + def backward1(): eegbrow.loc_sec -= 1 # print('keycode: ', keyboard.keycode) eegbrow.update() + def backward10(): eegbrow.loc_sec -= 10 # print('keycode: ', keyboard.keycode) eegbrow.update() - + + bForward1.on_click(forward1) bBackward1.on_click(backward1) @@ -216,46 +235,58 @@ def backward10(): # 'ArrowUp' : 38, # 'ArrowDown' : 40} + def keycallback(attr, old, new): - print('keycallback: ', attr, old, new, 'keycode:', keyboard.keycode) - if keyboard.keycode == 70: # KeyF + print("keycallback: ", attr, old, new, "keycode:", keyboard.keycode) + if keyboard.keycode == 70: # KeyF forward10() - if keyboard.keycode == 65: # KeyA + if keyboard.keycode == 65: # KeyA backward10() - if keyboard.keycode == 68: # KeyD + if keyboard.keycode == 68: # KeyD forward1() - if keyboard.keycode == 83: # KeyS + if keyboard.keycode == 83: # KeyS backward1() - if keyboard.keycode == 39: # ArrowRight + if keyboard.keycode == 39: # ArrowRight forward10() - if keyboard.keycode == 37: # ArrowLeft + if keyboard.keycode == 37: # ArrowLeft backward10() - if keyboard.keycode == 38: # ArrowUp - pass # increase gain - if keyboard.keycode == 40: # ArrowDown - pass # decrease gain - + if keyboard.keycode == 38: # ArrowUp + pass # increase gain + if keyboard.keycode == 40: # ArrowDown + pass # decrease gain + + def keycallback_print(attr, old, new): - print('keycallback: ', attr, old, new, 'keycode:', keyboard.keycode) + print("keycallback: ", attr, old, new, "keycode:", keyboard.keycode) + -keyboard.on_change('key_num_presses',keycallback) +keyboard.on_change("key_num_presses", keycallback) # keyboard.on_change('keycode',keycallback_print) - -bottomrowctrls = [bBackward10,bBackward1,bForward1, bForward10] -toprowctrls = [bokeh.models.widgets.Select(title='Montage',value='trace', options=['trace', 'db','tcp']), - bokeh.models.widgets.Select(title='Sensitivity',value='7uV/div', options=['1uV/div', '3uV/div','7uV/div','10uV/div']), - bokeh.models.widgets.Select(title='LF',value='0.3Hz', options=['None', '0.1Hz','0.3Hz','1Hz','5Hz']), - bokeh.models.widgets.Select(title='HF',value='70Hz', options=['None', '15Hz','30Hz','50Hz','70Hz']), +bottomrowctrls = [bBackward10, bBackward1, bForward1, bForward10] +toprowctrls = [ + bokeh.models.widgets.Select( + title="Montage", value="trace", options=["trace", "db", "tcp"] + ), + bokeh.models.widgets.Select( + title="Sensitivity", + value="7uV/div", + options=["1uV/div", "3uV/div", "7uV/div", "10uV/div"], + ), + bokeh.models.widgets.Select( + title="LF", value="0.3Hz", options=["None", "0.1Hz", "0.3Hz", "1Hz", "5Hz"] + ), + bokeh.models.widgets.Select( + title="HF", value="70Hz", options=["None", "15Hz", "30Hz", "50Hz", "70Hz"] + ), ] - -#for control in controls: -# control.on_change('value', lambda attr, old, new: update()) +# for control in controls: +# control.on_change('value', lambda attr, old, new: update()) -#inputs = widgetbox(*controls[:3], sizing_mode=SIZING_MODE) +# inputs = widgetbox(*controls[:3], sizing_mode=SIZING_MODE) wbox50 = functools.partial(layouts.widgetbox, sizing_mode=SIZING_MODE, width=MVT_BWIDTH) wbox20 = functools.partial(layouts.widgetbox, sizing_mode=SIZING_MODE, width=150) toprow = layouts.row(*map(wbox20, toprowctrls)) @@ -263,13 +294,16 @@ def keycallback_print(attr, old, new): print(toprow) bottomrow = layouts.row(*map(wbox50, bottomrowctrls)) -L = layouts.layout([ - [desc], - [toprow], - [mainfig], - [bottomrow], - [keyboard], - ], sizing_mode=SIZING_MODE) +L = layouts.layout( + [ + [desc], + [toprow], + [mainfig], + [bottomrow], + [keyboard], + ], + sizing_mode=SIZING_MODE, +) L.js_on_event DOC.add_root(L) diff --git a/experiments/example_app.py b/experiments/example_app.py index 720cfc2..8588f09 100644 --- a/experiments/example_app.py +++ b/experiments/example_app.py @@ -29,6 +29,7 @@ ds = r.data_source + # create a callback that will add a number in a random location def callback(): global i diff --git a/experiments/heatmap_experiments.py b/experiments/heatmap_experiments.py index f00a4a1..23b0e19 100644 --- a/experiments/heatmap_experiments.py +++ b/experiments/heatmap_experiments.py @@ -73,7 +73,7 @@ def stackplot_t( # data = np.random.randn(numSamples,numRows) # test data # data.shape = numSamples, numRows if seconds: - t = seconds * np.arange(numSamples, dtype=float) / (numSamples-1) + t = seconds * np.arange(numSamples, dtype=float) / (numSamples - 1) # import pdb # pdb.set_trace() if start_time: @@ -279,13 +279,14 @@ def stackplot_t( alpha=0.5, zorder=3, cmap=colorcet.cm.bmw, -) # inferno, magma, viridis, cividis, etc -# colorcet.cm.fire, colorcet.cm.blues, gray, bgwy, bmw, etc. +) # inferno, magma, viridis, cividis, etc +# colorcet.cm.fire, colorcet.cm.blues, gray, bgwy, bmw, etc. # %% from colorcet.plotting import swatch, swatches import holoviews as hv -hv.extension('matplotlib') + +hv.extension("matplotlib") # %% swatches() @@ -295,18 +296,18 @@ def stackplot_t( # - try using alpha to mask image # %% -alphamask = np.zeros((NUM_CH,NUM_CHUNKS,4),dtype=np.float64) +alphamask = np.zeros((NUM_CH, NUM_CHUNKS, 4), dtype=np.float64) -Z = float(NUM_CH*NUM_CHUNKS) +Z = float(NUM_CH * NUM_CHUNKS) for ii in range(NUM_CH): for jj in range(NUM_CHUNKS): - alphamask[ii,jj,3] = ii*jj/Z + alphamask[ii, jj, 3] = ii * jj / Z -plt.imshow(alphamask) +plt.imshow(alphamask) # %% fig, axarr = plt.subplots(1, 1) -fig.set_size_inches(2*FIGSIZE[0],2* 2 * FIGSIZE[1]) +fig.set_size_inches(2 * FIGSIZE[0], 2 * 2 * FIGSIZE[1]) # print() # print(axarr, f"clip_length (sec): {clip_length},", f"seconds = {clip_length*NUM_CHUNKS},") eegax = stacklineplot.stackplot_t( @@ -327,17 +328,17 @@ def stackplot_t( # interpolation="bilinear", aspect="auto", extent=[left, right, bottom, top], - #alpha=0.5, + # alpha=0.5, zorder=3, # cmap="inferno", ) # inferno, magma, viridis, cividis, etc # %% testeegalpha = alphamask.copy() -testeegalpha[:,:,3] = 1-heatmap_ex +testeegalpha[:, :, 3] = 1 - heatmap_ex # %% fig, axarr = plt.subplots(1, 1) -fig.set_size_inches(2*FIGSIZE[0], 2*2 * FIGSIZE[1]) +fig.set_size_inches(2 * FIGSIZE[0], 2 * 2 * FIGSIZE[1]) # print() # print(axarr, f"clip_length (sec): {clip_length},", f"seconds = {clip_length*NUM_CHUNKS},") eegax = stacklineplot.stackplot_t( @@ -353,12 +354,11 @@ def stackplot_t( # choose to overwrite plot with image but use alpha to modify blending # if want EEG plot on top then set zorder to lower like 0 axarr.imshow( - testeegalpha, #if add alpha values to this image + testeegalpha, # if add alpha values to this image origin="upper", - #interpolation="bilinear", + # interpolation="bilinear", aspect="auto", extent=[left, right, bottom, top], - zorder=3, cmap="inferno", ) # inferno, magma, viridis, cividis, etc @@ -366,7 +366,7 @@ def stackplot_t( # %% fig, axarr = plt.subplots(1, 1) -fig.set_size_inches(2*FIGSIZE[0], 2*2 * FIGSIZE[1]) +fig.set_size_inches(2 * FIGSIZE[0], 2 * 2 * FIGSIZE[1]) # print() # print(axarr, f"clip_length (sec): {clip_length},", f"seconds = {clip_length*NUM_CHUNKS},") eegax = stacklineplot.stackplot_t( @@ -387,7 +387,6 @@ def stackplot_t( interpolation="bilinear", aspect="auto", extent=[left, right, bottom, top], - zorder=3, cmap="inferno", ) # inferno, magma, viridis, cividis, etc diff --git a/experiments/javascript.ipynb b/experiments/javascript.ipynb index 18186e9..407e736 100644 --- a/experiments/javascript.ipynb +++ b/experiments/javascript.ipynb @@ -142,7 +142,7 @@ } ], "source": [ - "# if this is reached via chrome short cut like /chrome.exe --app=http://localhost:5000/notebooks/code/experiments/javascript.ipynb " + "# if this is reached via chrome short cut like /chrome.exe --app=http://localhost:5000/notebooks/code/experiments/javascript.ipynb" ] }, { diff --git a/experiments/newwidget/main.py b/experiments/newwidget/main.py index c8c4ce9..53ece0e 100644 --- a/experiments/newwidget/main.py +++ b/experiments/newwidget/main.py @@ -20,19 +20,18 @@ yy = yy.ravel() - def compute(t): - value = np.sin(xx/50 + t/10) * np.cos(yy/50 + t/10) * 50 + 50 + value = np.sin(xx / 50 + t / 10) * np.cos(yy / 50 + t / 10) * 50 + 50 # print("doing compute..") return dict(x=xx, y=yy, z=value, color=value) -b = Button(name='test') + +b = Button(name="test") source = ColumnDataSource(data=compute(0)) -#content_filename = join(dirname(__file__), "description.html") +# content_filename = join(dirname(__file__), "description.html") -description = Div(text="some text to describe this", - render_as_text=False, width=600) +description = Div(text="some text to describe this", render_as_text=False, width=600) # surface = Surface3d(x="x", y="y", z="z", color="color", data_source=source) nwidget = NewWidget() @@ -40,9 +39,11 @@ def compute(t): curdoc().add_root(layouts.column([description, b, layouts.WidgetBox(nwidget)])) # doc.add_root(layouts.column([description, b])) + @count() def update(t): source.data = compute(t) + doc.add_periodic_callback(update, 100) doc.title = "NewWidget" diff --git a/experiments/newwidget/newwidget.py b/experiments/newwidget/newwidget.py index e7a02ed..2a06754 100644 --- a/experiments/newwidget/newwidget.py +++ b/experiments/newwidget/newwidget.py @@ -1,4 +1,10 @@ -from bokeh.core.properties import Any, Dict, Instance, String, Int # List, Angle, Auto, Bool, Byte, Color, Complex, Date, Float, Interval, JSON, MinMaxBounds, Percent, Regex, Size, TimeDelta, Dict, RelativeDelta, Seq, Tuple, +from bokeh.core.properties import ( + Any, + Dict, + Instance, + String, + Int, +) # List, Angle, Auto, Bool, Byte, Color, Complex, Date, Float, Interval, JSON, MinMaxBounds, Percent, Regex, Size, TimeDelta, Dict, RelativeDelta, Seq, Tuple, from bokeh.models import ColumnDataSource from bokeh.models import LayoutDOM @@ -12,7 +18,6 @@ class NewWidget(LayoutDOM): - # The special class attribute ``__implementation__`` should contain a string # of JavaScript (or CoffeeScript) code that implements the JavaScript side # of the custom extension model. @@ -36,7 +41,7 @@ class NewWidget(LayoutDOM): # be used for each field. # x = String - #y = String + # y = String # z = String @@ -47,4 +52,3 @@ class NewWidget(LayoutDOM): # options = Dict(String, Any, default=DEFAULTS) keycode = Int - diff --git a/notebooks/eegbrowser.ipynb b/notebooks/eegbrowser.ipynb index 7bc2b96..8c0eda0 100644 --- a/notebooks/eegbrowser.ipynb +++ b/notebooks/eegbrowser.ipynb @@ -351,7 +351,6 @@ "metadata": {}, "outputs": [], "source": [ - "\n", "import eegvis.nb_eegview as nb_eegview\n", "import eeghdf" ] @@ -580,6 +579,7 @@ ], "source": [ "import scipy\n", + "\n", "scipy.__version__" ] }, diff --git a/notebooks/eegbrowser.py b/notebooks/eegbrowser.py index b50fc01..9ca5762 100644 --- a/notebooks/eegbrowser.py +++ b/notebooks/eegbrowser.py @@ -60,6 +60,7 @@ # %% import scipy + scipy.__version__ # %% diff --git a/notebooks/test-bokeh-ipyEEGPlot.py b/notebooks/test-bokeh-ipyEEGPlot.py index 1c0f20a..a1ffc14 100644 --- a/notebooks/test-bokeh-ipyEEGPlot.py +++ b/notebooks/test-bokeh-ipyEEGPlot.py @@ -32,41 +32,52 @@ import eegvis.montageview as montageview import eegvis.stackplot_bokeh as sbokplot from bokeh.io import output_notebook, push_notebook -#import bokeh.plotting as bplt -#from bokeh.plotting import show + +# import bokeh.plotting as bplt +# from bokeh.plotting import show output_notebook() -ARCHIVEDIR = r'../../eeg-hdfstorage/notebooks/archive' +ARCHIVEDIR = r"../../eeg-hdfstorage/notebooks/archive" # %% [markdown] slideshow={"slide_type": "slide"} # ### load the hdf eeg file we are interested in # %% slideshow={"slide_type": "fragment"} -#hdf = h5py.File('./archive/YA2741BS_1-1+.eeghdf') # 5mo boy -hdf = h5py.File(os.path.join(ARCHIVEDIR,'YA2741G2_1-1+.eeghdf')) # absence 10yo +# hdf = h5py.File('./archive/YA2741BS_1-1+.eeghdf') # 5mo boy +hdf = h5py.File(os.path.join(ARCHIVEDIR, "YA2741G2_1-1+.eeghdf")) # absence 10yo # %% slideshow={"slide_type": "slide"} -rec = hdf['record-0'] -years_old = rec.attrs['patient_age_days']/365 +rec = hdf["record-0"] +years_old = rec.attrs["patient_age_days"] / 365 # %% slideshow={"slide_type": "slide"} -signals = rec['signals'] -labels = rec['signal_labels'] -electrode_labels = [str(s,'ascii') for s in labels] +signals = rec["signals"] +labels = rec["signal_labels"] +electrode_labels = [str(s, "ascii") for s in labels] ref_labels = montageview.standard2shortname(electrode_labels) -numbered_electrode_labels = ["%d:%s" % (ii, str(labels[ii], 'ascii')) for ii in range(len(labels))] +numbered_electrode_labels = [ + "%d:%s" % (ii, str(labels[ii], "ascii")) for ii in range(len(labels)) +] # %% slideshow={"slide_type": "slide"} -inr = sbokplot.IpyEEGPlot(signals, 15, electrode_labels=electrode_labels, fs=rec.attrs['sample_frequency']) +inr = sbokplot.IpyEEGPlot( + signals, 15, electrode_labels=electrode_labels, fs=rec.attrs["sample_frequency"] +) inr.show() # %% [markdown] slideshow={"slide_type": "slide"} # ### Try showing fewer channels for better visualization # %% slideshow={"slide_type": "fragment"} -smallerplot = sbokplot.IpyEEGPlot(signals, 15, electrode_labels=electrode_labels, fs=rec.attrs['sample_frequency'], showchannels=(0,21)) +smallerplot = sbokplot.IpyEEGPlot( + signals, + 15, + electrode_labels=electrode_labels, + fs=rec.attrs["sample_frequency"], + showchannels=(0, 21), +) smallerplot.show() # %% slideshow={"slide_type": "skip"} @@ -82,4 +93,3 @@ bokeh.__version__ # %% - diff --git a/notebooks/test-bokeh-ipyHdfEegPlot-db-eeghdf.ipynb b/notebooks/test-bokeh-ipyHdfEegPlot-db-eeghdf.ipynb index 7fd967d..e165390 100644 --- a/notebooks/test-bokeh-ipyHdfEegPlot-db-eeghdf.ipynb +++ b/notebooks/test-bokeh-ipyHdfEegPlot-db-eeghdf.ipynb @@ -351,7 +351,7 @@ "source": [ "# %load explore-eeghdf-files-basics.py\n", "# Here is an example of how to do basic exploration of what is in the eeghdf file. I show how to discover the fields in the file and to plot them.\n", - "# \n", + "#\n", "# I have copied the stacklineplot from my python-edf/examples code to help with display. Maybe I will put this as a helper or put it out as a utility package to make it easier to install.\n", "\n", "from __future__ import print_function, division, unicode_literals\n", @@ -370,10 +370,11 @@ "from bokeh.io import output_notebook, push_notebook\n", "import bokeh.plotting as bplt\n", "from bokeh.plotting import show\n", + "\n", "output_notebook()\n", "\n", - "ARCHIVEDIR = r'../../eeghdf/data'\n", - "EEGFILE = os.path.join(ARCHIVEDIR, 'spasms.eeghdf')\n" + "ARCHIVEDIR = r\"../../eeghdf/data\"\n", + "EEGFILE = os.path.join(ARCHIVEDIR, \"spasms.eeghdf\")" ] }, { @@ -394,9 +395,9 @@ } ], "source": [ - "#hdf = h5py.File('./archive/YA2741BS_1-1+.eeghdf') # 5mo boy \n", + "# hdf = h5py.File('./archive/YA2741BS_1-1+.eeghdf') # 5mo boy\n", "print(EEGFILE)\n", - "hf = eeghdf.Eeghdf(EEGFILE)# absence 10yo\n" + "hf = eeghdf.Eeghdf(EEGFILE) # absence 10yo" ] }, { @@ -539,7 +540,9 @@ } ], "source": [ - "tmp = sbokplot.IpyHdfEegPlot(hf,page_width_seconds=15, showchannels=(0,19)) # doing this just to make the labels" + "tmp = sbokplot.IpyHdfEegPlot(\n", + " hf, page_width_seconds=15, showchannels=(0, 19)\n", + ") # doing this just to make the labels" ] }, { @@ -564,10 +567,10 @@ "outputs": [], "source": [ "db = montageview.DoubleBananaMontageView(rec_labels=hf.shortcut_elabels)\n", - "inr = sbokplot.IpyHdfEegPlot(hf.hdf,page_width_seconds=15, montage=db)\n", + "inr = sbokplot.IpyHdfEegPlot(hf.hdf, page_width_seconds=15, montage=db)\n", "\n", "# inr.all_montages.append(db)\n", - "#inr.current_montage = db" + "# inr.current_montage = db" ] }, { @@ -733,6 +736,7 @@ ], "source": [ "import bokeh\n", + "\n", "bokeh.__version__" ] }, diff --git a/notebooks/test-nb_eegview.ipynb b/notebooks/test-nb_eegview.ipynb index 83f3dd8..e0c5bc1 100644 --- a/notebooks/test-nb_eegview.ipynb +++ b/notebooks/test-nb_eegview.ipynb @@ -14,7 +14,7 @@ } ], "source": [ - "#%pdb on" + "# %pdb on" ] }, { @@ -358,6 +358,7 @@ "from bokeh.io import output_notebook, push_notebook\n", "import bokeh.plotting as bplt\n", "from bokeh.plotting import show\n", + "\n", "output_notebook()\n", "\n", "# current testing based upon a anaconda=5.2 conda environment\n", @@ -385,6 +386,7 @@ ], "source": [ "from IPython.core.display import display, HTML\n", + "\n", "display(HTML(\"\"))" ] }, @@ -394,9 +396,9 @@ "metadata": {}, "outputs": [], "source": [ - "ARCHIVEDIR = r'../../eeghdf/data/'\n", - "#EEGFILE = ARCHIVEDIR + 'spasms.eeghdf'\n", - "EEGFILE = ARCHIVEDIR + 'absence_epilepsy.eeghdf'" + "ARCHIVEDIR = r\"../../eeghdf/data/\"\n", + "# EEGFILE = ARCHIVEDIR + 'spasms.eeghdf'\n", + "EEGFILE = ARCHIVEDIR + \"absence_epilepsy.eeghdf\"" ] }, { @@ -436,7 +438,9 @@ }, "outputs": [], "source": [ - "eegbrow = nb_eegview.EeghdfBrowser(hf, montage='double banana', start_seconds=1385, plot_width=1800, plot_height=800)" + "eegbrow = nb_eegview.EeghdfBrowser(\n", + " hf, montage=\"double banana\", start_seconds=1385, plot_width=1800, plot_height=800\n", + ")" ] }, { @@ -536,7 +540,7 @@ "metadata": {}, "outputs": [], "source": [ - "f = eegbrow._highpass_cache['5 Hz'] # grab one of the filters" + "f = eegbrow._highpass_cache[\"5 Hz\"] # grab one of the filters" ] }, { @@ -545,7 +549,7 @@ "metadata": {}, "outputs": [], "source": [ - "eegbrow.yscale = 3.0 # interact with live plot\n", + "eegbrow.yscale = 3.0 # interact with live plot\n", "eegbrow.update()" ] }, @@ -581,11 +585,14 @@ "# experiment with more anotations, need to add a scale bar\n", "import bokeh\n", "from bokeh.models.annotations import BoxAnnotation, Arrow\n", - "t = eegbrow.loc_sec - eegbrow.page_width_seconds/2.0\n", - "#scalbox = BoxAnnotation(left=t, right=t+0.5, top=500.0, bottom=0, fill_color='gray', fill_alpha=0.5)\n", - "#eegbrow.fig.add_layout(scalbox)\n", - "arwV = Arrow(x_start=t, x_end=t, y_start=0, y_end=500.0, end=None) # don't draw an arrow head\n", - "arwH = Arrow(x_start=t, x_end=t+0.5, y_start=0, y_end=0, end=None)\n", + "\n", + "t = eegbrow.loc_sec - eegbrow.page_width_seconds / 2.0\n", + "# scalbox = BoxAnnotation(left=t, right=t+0.5, top=500.0, bottom=0, fill_color='gray', fill_alpha=0.5)\n", + "# eegbrow.fig.add_layout(scalbox)\n", + "arwV = Arrow(\n", + " x_start=t, x_end=t, y_start=0, y_end=500.0, end=None\n", + ") # don't draw an arrow head\n", + "arwH = Arrow(x_start=t, x_end=t + 0.5, y_start=0, y_end=0, end=None)\n", "eegbrow.fig.add_layout(arwV)\n", "eegbrow.fig.add_layout(arwH)\n", "eegbrow.push_notebook()" @@ -597,7 +604,8 @@ "metadata": {}, "outputs": [], "source": [ - "arwV.x_start = 1384.0; arwV.x_end = 1384.0\n", + "arwV.x_start = 1384.0\n", + "arwV.x_end = 1384.0\n", "eegbrow.push_notebook()" ] }, diff --git a/notebooks/use-db-ref-montage.py b/notebooks/use-db-ref-montage.py index 5f2be72..9af7967 100644 --- a/notebooks/use-db-ref-montage.py +++ b/notebooks/use-db-ref-montage.py @@ -18,17 +18,19 @@ # %% slideshow={"slide_type": "fragment"} # # %load explore-eeghdf-files-basics.py # Here is an example of how to do basic exploration of what is in the eeghdf file. I show how to discover the fields in the file and to plot them. -# +# # I have copied the stacklineplot from my python-edf/examples code to help with display. Maybe I will put this as a helper or put it out as a utility package to make it easier to install. from __future__ import print_function, division, unicode_literals import os.path + # %matplotlib inline # # %matplotlib notebook import pathlib import matplotlib import matplotlib.pyplot as plt -#import seaborn + +# import seaborn import pandas as pd import numpy as np import h5py @@ -39,60 +41,67 @@ import eegvis.montageview as montageview # matplotlib.rcParams['figure.figsize'] = (18.0, 12.0) -matplotlib.rcParams['figure.figsize'] = (12.0, 8.0) -ARCHIVEDIR = r'../../eeghdf/data' -EEGFILE = os.path.join(ARCHIVEDIR, 'spasms.eeghdf') +matplotlib.rcParams["figure.figsize"] = (12.0, 8.0) +ARCHIVEDIR = r"../../eeghdf/data" +EEGFILE = os.path.join(ARCHIVEDIR, "spasms.eeghdf") # %% # ls -l ../../eeghdf/data # %% slideshow={"slide_type": "slide"} -hdf = h5py.File(EEGFILE) +hdf = h5py.File(EEGFILE) pt = pathlib.Path(EEGFILE) pt.is_file() # %% slideshow={"slide_type": "slide"} -#hf = eeghdf.Eeghdf(EEGFILE) +# hf = eeghdf.Eeghdf(EEGFILE) hdf.keys() # %% slideshow={"slide_type": "slide"} -rec = hdf['record-0'] -years_old = rec.attrs['patient_age_days']/365 +rec = hdf["record-0"] +years_old = rec.attrs["patient_age_days"] / 365 pprint("age in years: %s" % years_old) # %% slideshow={"slide_type": "slide"} -signals = rec['signals'] -labels = rec['signal_labels'] -electrode_labels = [str(s,'ascii') for s in labels] -numbered_electrode_labels = ["%d:%s" % (ii, str(labels[ii], 'ascii')) for ii in range(len(labels))] +signals = rec["signals"] +labels = rec["signal_labels"] +electrode_labels = [str(s, "ascii") for s in labels] +numbered_electrode_labels = [ + "%d:%s" % (ii, str(labels[ii], "ascii")) for ii in range(len(labels)) +] # %% [markdown] slideshow={"slide_type": "slide"} # #### Simple visualization of EEG (electrodecrement seizure pattern) # %% slideshow={"slide_type": "fragment"} # plot 10s epochs (multiples in DE) -ch0, ch1 = (0,19) -DE = 2 # how many 10s epochs to display -epoch = 53; ptepoch = 10*int(rec.attrs['sample_frequency']) -dp = int(0.5*ptepoch) +ch0, ch1 = (0, 19) +DE = 2 # how many 10s epochs to display +epoch = 53 +ptepoch = 10 * int(rec.attrs["sample_frequency"]) +dp = int(0.5 * ptepoch) # stacklineplot.stackplot(signals[ch0:ch1,epoch*ptepoch+dp:(epoch+DE)*ptepoch+dp],seconds=DE*10.0, ylabels=electrode_labels[ch0:ch1], yscale=0.3) print("epoch:", epoch) - # %% # search identified spasms at 1836, 1871, 1901, 1939 -stacklineplot.show_epoch_centered(signals, 1836, - epoch_width_sec=15, - chstart=0, chstop=19, fs=rec.attrs['sample_frequency'], - ylabels=electrode_labels, yscale=3.0) - +stacklineplot.show_epoch_centered( + signals, + 1836, + epoch_width_sec=15, + chstart=0, + chstop=19, + fs=rec.attrs["sample_frequency"], + ylabels=electrode_labels, + yscale=3.0, +) # %% electrode_labels -#r_labels = [ss.replace('EEG ','') for ss in electrode_labels] +# r_labels = [ss.replace('EEG ','') for ss in electrode_labels] # r_labels ref_labels = montageview.standard2shortname(electrode_labels) ref_labels @@ -101,17 +110,22 @@ montageview.DB_LABELS # %% -db_ref = montageview.MONTAGE_BUILTINS['DB-REF'](ref_labels) -#odict_keys(['trace', 'tcp', 'double banana', 'laplacian', 'neonatal', 'DB-REF']) +db_ref = montageview.MONTAGE_BUILTINS["DB-REF"](ref_labels) +# odict_keys(['trace', 'tcp', 'double banana', 'laplacian', 'neonatal', 'DB-REF']) # %% # %% -stacklineplot.show_montage_centered(signals, # numpy array-like (channel, samplenum) shape - db_ref, 1836, - epoch_width_sec=15, - chstart=0, chstop=19, fs=rec.attrs['sample_frequency'], - yscale=3.0) +stacklineplot.show_montage_centered( + signals, # numpy array-like (channel, samplenum) shape + db_ref, + 1836, + epoch_width_sec=15, + chstart=0, + chstop=19, + fs=rec.attrs["sample_frequency"], + yscale=3.0, +) # %% # stacklineplot.show_montage_centered? diff --git a/notebooks/use-stacklineplot-with-eeghdf-files-and-abs-gain.py b/notebooks/use-stacklineplot-with-eeghdf-files-and-abs-gain.py index a426067..e90a722 100644 --- a/notebooks/use-stacklineplot-with-eeghdf-files-and-abs-gain.py +++ b/notebooks/use-stacklineplot-with-eeghdf-files-and-abs-gain.py @@ -39,7 +39,7 @@ import eegvis.stacklineplot as stacklineplot import eegvis.montageview as montageview -#%% +# %% matplotlib.rcParams["figure.dpi"] = 100 # 100 dpi matplotlib.rcParams["figure.figsize"] = (8, 6) # %% @@ -103,7 +103,7 @@ ylabels=ylabels, ) -#%% +# %% stacklineplot.show_epoch_centered( signals, goto_sec, @@ -138,7 +138,7 @@ chstop=chstop, ylabels=ylabels, ) -#%% +# %% plt.figure(figsize=(16, 8)) stacklineplot.show_epoch_centered( signals, @@ -187,7 +187,7 @@ ysensitivity=7.0, ) # the yscale multiples the signals by this number, it is a bit of a hack -#%% +# %% print("plot with ysensitivity=20.0") plt.figure(figsize=(16, 8)) stacklineplot.show_montage_centered( @@ -217,7 +217,9 @@ # %% stacklineplot.stackplot_t_with_heatmap( - tarray, seconds=10.0, heatmap_image=heatmap_ex # without seconds shows samples + tarray, + seconds=10.0, + heatmap_image=heatmap_ex, # without seconds shows samples ) # %% [markdown] diff --git a/notebooks/use-stacklineplot-with-eeghdf-files.ipynb b/notebooks/use-stacklineplot-with-eeghdf-files.ipynb index 0a9ddf7..1ed551f 100644 --- a/notebooks/use-stacklineplot-with-eeghdf-files.ipynb +++ b/notebooks/use-stacklineplot-with-eeghdf-files.ipynb @@ -30,7 +30,8 @@ "\n", "import matplotlib\n", "import matplotlib.pyplot as plt\n", - "#import seaborn\n", + "\n", + "# import seaborn\n", "import pandas as pd\n", "import numpy as np\n", "import eeghdf\n", @@ -74,10 +75,10 @@ "outputs": [], "source": [ "signals = hf.phys_signals\n", - "goto_sec = 5.0 # note since this centered here, to show first 10 seconds need to set this to 5 or epoch_width_sec/2\n", - "epoch_width_sec = 10.0 #seconds\n", + "goto_sec = 5.0 # note since this centered here, to show first 10 seconds need to set this to 5 or epoch_width_sec/2\n", + "epoch_width_sec = 10.0 # seconds\n", "FS = hf.sample_frequency\n", - "chstart =0\n", + "chstart = 0\n", "chstop = 19\n", "ylabels = hf.electrode_labels\n", "yscale = 1.0" @@ -147,7 +148,15 @@ "source": [ "# the simpliest thing you can do is display the eeg as it is recorded\n", "\n", - "stacklineplot.show_epoch_centered(signals, goto_sec, epoch_width_sec, fs=FS, chstart=chstart, chstop=chstop, ylabels=ylabels)" + "stacklineplot.show_epoch_centered(\n", + " signals,\n", + " goto_sec,\n", + " epoch_width_sec,\n", + " fs=FS,\n", + " chstart=chstart,\n", + " chstop=chstop,\n", + " ylabels=ylabels,\n", + ")" ] }, { @@ -157,10 +166,10 @@ "outputs": [], "source": [ "signals = hf.phys_signals\n", - "goto_sec = 15.0 # note since this centered here, to show first 10 seconds need to set this to 5 or epoch_width_sec/2\n", - "epoch_width_sec = 30.0 #seconds\n", + "goto_sec = 15.0 # note since this centered here, to show first 10 seconds need to set this to 5 or epoch_width_sec/2\n", + "epoch_width_sec = 30.0 # seconds\n", "FS = hf.sample_frequency\n", - "chstart =0\n", + "chstart = 0\n", "chstop = 19\n", "ylabels = hf.shortcut_elabels\n", "yscale = 1.0" @@ -195,9 +204,17 @@ } ], "source": [ - "plt.figure(figsize=(16,8))\n", + "plt.figure(figsize=(16, 8))\n", "\n", - "stacklineplot.show_epoch_centered(signals, goto_sec, epoch_width_sec, fs=FS, chstart=chstart, chstop=chstop, ylabels=ylabels)" + "stacklineplot.show_epoch_centered(\n", + " signals,\n", + " goto_sec,\n", + " epoch_width_sec,\n", + " fs=FS,\n", + " chstart=chstart,\n", + " chstop=chstop,\n", + " ylabels=ylabels,\n", + ")" ] }, { @@ -250,7 +267,7 @@ "# create another montage view for ourselves, we can use predefined ones\n", "# these are factory functions which need to know the relationship bewteen labels and signal number\n", "# can see current defined ones in montageview.MONTAGE_BUILTINS.keys()\n", - "db = montageview.MONTAGE_BUILTINS['double banana'](hf.shortcut_elabels)" + "db = montageview.MONTAGE_BUILTINS[\"double banana\"](hf.shortcut_elabels)" ] }, { @@ -282,14 +299,17 @@ } ], "source": [ - "plt.figure(figsize=(16,8))\n", - "stacklineplot.show_montage_centered(signals, db, # put montage here\n", - " goto_sec,\n", - " epoch_width_sec,\n", - " chstart,\n", - " chstop,\n", - " FS,\n", - " yscale=3.0) # the yscale multiples the signals by this number, it is a bit of a hack" + "plt.figure(figsize=(16, 8))\n", + "stacklineplot.show_montage_centered(\n", + " signals,\n", + " db, # put montage here\n", + " goto_sec,\n", + " epoch_width_sec,\n", + " chstart,\n", + " chstop,\n", + " FS,\n", + " yscale=3.0,\n", + ") # the yscale multiples the signals by this number, it is a bit of a hack" ] }, { @@ -351,7 +371,7 @@ "metadata": {}, "outputs": [], "source": [ - "tarray = signals[0:19,0:int(FS*10)].T" + "tarray = signals[0:19, 0 : int(FS * 10)].T" ] }, { @@ -383,9 +403,11 @@ } ], "source": [ - "stacklineplot.stackplot_t_with_heatmap(tarray,\n", - " seconds=10.0, # without seconds shows samples\n", - " heatmap_image=heatmap_ex)" + "stacklineplot.stackplot_t_with_heatmap(\n", + " tarray,\n", + " seconds=10.0, # without seconds shows samples\n", + " heatmap_image=heatmap_ex,\n", + ")" ] }, { diff --git a/notebooks/use-stacklineplot-with-eeghdf-files.py b/notebooks/use-stacklineplot-with-eeghdf-files.py index 7662a2e..ba559e5 100644 --- a/notebooks/use-stacklineplot-with-eeghdf-files.py +++ b/notebooks/use-stacklineplot-with-eeghdf-files.py @@ -29,7 +29,8 @@ import matplotlib import matplotlib.pyplot as plt -#import seaborn + +# import seaborn import pandas as pd import numpy as np import eeghdf @@ -49,10 +50,10 @@ # %% signals = hf.phys_signals -goto_sec = 5.0 # note since this centered here, to show first 10 seconds need to set this to 5 or epoch_width_sec/2 -epoch_width_sec = 10.0 #seconds +goto_sec = 5.0 # note since this centered here, to show first 10 seconds need to set this to 5 or epoch_width_sec/2 +epoch_width_sec = 10.0 # seconds FS = hf.sample_frequency -chstart =0 +chstart = 0 chstop = 19 ylabels = hf.electrode_labels yscale = 1.0 @@ -89,22 +90,38 @@ # %% # the simpliest thing you can do is display the eeg as it is recorded -stacklineplot.show_epoch_centered(signals, goto_sec, epoch_width_sec, fs=FS, chstart=chstart, chstop=chstop, ylabels=ylabels) +stacklineplot.show_epoch_centered( + signals, + goto_sec, + epoch_width_sec, + fs=FS, + chstart=chstart, + chstop=chstop, + ylabels=ylabels, +) # %% signals = hf.phys_signals -goto_sec = 15.0 # note since this centered here, to show first 10 seconds need to set this to 5 or epoch_width_sec/2 -epoch_width_sec = 30.0 #seconds +goto_sec = 15.0 # note since this centered here, to show first 10 seconds need to set this to 5 or epoch_width_sec/2 +epoch_width_sec = 30.0 # seconds FS = hf.sample_frequency -chstart =0 +chstart = 0 chstop = 19 ylabels = hf.shortcut_elabels yscale = 1.0 # %% -plt.figure(figsize=(16,8)) +plt.figure(figsize=(16, 8)) -stacklineplot.show_epoch_centered(signals, goto_sec, epoch_width_sec, fs=FS, chstart=chstart, chstop=chstop, ylabels=ylabels) +stacklineplot.show_epoch_centered( + signals, + goto_sec, + epoch_width_sec, + fs=FS, + chstart=chstart, + chstop=chstop, + ylabels=ylabels, +) # %% # stacklineplot.stackplot? @@ -113,17 +130,20 @@ # create another montage view for ourselves, we can use predefined ones # these are factory functions which need to know the relationship bewteen labels and signal number # can see current defined ones in montageview.MONTAGE_BUILTINS.keys() -db = montageview.MONTAGE_BUILTINS['double banana'](hf.shortcut_elabels) +db = montageview.MONTAGE_BUILTINS["double banana"](hf.shortcut_elabels) # %% -plt.figure(figsize=(16,8)) -stacklineplot.show_montage_centered(signals, db, # put montage here - goto_sec, - epoch_width_sec, - chstart, - chstop, - FS, - yscale=3.0) # the yscale multiples the signals by this number, it is a bit of a hack +plt.figure(figsize=(16, 8)) +stacklineplot.show_montage_centered( + signals, + db, # put montage here + goto_sec, + epoch_width_sec, + chstart, + chstop, + FS, + yscale=3.0, +) # the yscale multiples the signals by this number, it is a bit of a hack # %% # now lets try a the simplier stackplot and heatmap @@ -138,12 +158,14 @@ plt.imshow(heatmap_ex) # %% -tarray = signals[0:19,0:int(FS*10)].T +tarray = signals[0:19, 0 : int(FS * 10)].T # %% -stacklineplot.stackplot_t_with_heatmap(tarray, - seconds=10.0, # without seconds shows samples - heatmap_image=heatmap_ex) +stacklineplot.stackplot_t_with_heatmap( + tarray, + seconds=10.0, # without seconds shows samples + heatmap_image=heatmap_ex, +) # %% [markdown] # ``` @@ -175,10 +197,10 @@ # # @ax is the option to pass in a matplotlib axes obj to draw with # @heatmap_image should be an ndarray usually this will be of shape something like (NUM_CH, NUM_TIME_STEPS) -# @alpha is how to blend this +# @alpha is how to blend this # # generally want to choose a perceptually uniform colormap -# inferno, magma, viridis, cividis, etc see also +# inferno, magma, viridis, cividis, etc see also # colorcet.cm.fire, .bmw etc for excellent colormaps # # >>> heatmap_image = np.random.uniform(size=(NUM_CH, NUM_CHUNKS)) diff --git a/notebooks/vizSpasm-doubleban.py b/notebooks/vizSpasm-doubleban.py index 7de42d1..417738a 100644 --- a/notebooks/vizSpasm-doubleban.py +++ b/notebooks/vizSpasm-doubleban.py @@ -19,19 +19,20 @@ # # %load explore-eeghdf-files-basics.py # Here is an example of how to do basic exploration of what is in the eeghdf file. I show how to discover the fields in the file and to plot them. -# +# # I have copied the stacklineplot from my python-edf/examples code to help with display. Maybe I will put this as a helper or put it out as a utility package to make it easier to install. from __future__ import print_function, division, unicode_literals import os.path -get_ipython().run_line_magic('matplotlib', 'inline') +get_ipython().run_line_magic("matplotlib", "inline") # %matplotlib notebook import matplotlib import matplotlib.pyplot as plt -#import seaborn + +# import seaborn import pandas as pd import numpy as np import h5py @@ -41,30 +42,32 @@ import eegvis.montageview as montageview # matplotlib.rcParams['figure.figsize'] = (18.0, 12.0) -matplotlib.rcParams['figure.figsize'] = (12.0, 8.0) +matplotlib.rcParams["figure.figsize"] = (12.0, 8.0) -ARCHIVEDIR = r'../../eeghdf/data' -EEGFILE = os.path.join(ARCHIVEDIR, 'YA2741BS_1-1+.eeghdf') +ARCHIVEDIR = r"../../eeghdf/data" +EEGFILE = os.path.join(ARCHIVEDIR, "YA2741BS_1-1+.eeghdf") # %% # ls ../../eeghdf/data # %% slideshow={"slide_type": "slide"} -hdf = h5py.File(EEGFILE) +hdf = h5py.File(EEGFILE) # %% slideshow={"slide_type": "slide"} -rec = hdf['record-0'] -years_old = rec.attrs['patient_age_days']/365 +rec = hdf["record-0"] +years_old = rec.attrs["patient_age_days"] / 365 pprint("age in years: %s" % years_old) # %% jupyter={"outputs_hidden": true} slideshow={"slide_type": "slide"} -signals = rec['signals'] -labels = rec['signal_labels'] -electrode_labels = [str(s,'ascii') for s in labels] -numbered_electrode_labels = ["%d:%s" % (ii, str(labels[ii], 'ascii')) for ii in range(len(labels))] +signals = rec["signals"] +labels = rec["signal_labels"] +electrode_labels = [str(s, "ascii") for s in labels] +numbered_electrode_labels = [ + "%d:%s" % (ii, str(labels[ii], "ascii")) for ii in range(len(labels)) +] # %% [markdown] slideshow={"slide_type": "slide"} @@ -73,29 +76,35 @@ # %% slideshow={"slide_type": "fragment"} # plot 10s epochs (multiples in DE) -ch0, ch1 = (0,19) -DE = 2 # how many 10s epochs to display -epoch = 53; ptepoch = 10*int(rec.attrs['sample_frequency']) -dp = int(0.5*ptepoch) +ch0, ch1 = (0, 19) +DE = 2 # how many 10s epochs to display +epoch = 53 +ptepoch = 10 * int(rec.attrs["sample_frequency"]) +dp = int(0.5 * ptepoch) # stacklineplot.stackplot(signals[ch0:ch1,epoch*ptepoch+dp:(epoch+DE)*ptepoch+dp],seconds=DE*10.0, ylabels=electrode_labels[ch0:ch1], yscale=0.3) print("epoch:", epoch) # search identified spasms at 1836, 1871, 1901, 1939 -stacklineplot.show_epoch_centered(signals, 1836, - epoch_width_sec=15, - chstart=0, chstop=19, fs=rec.attrs['sample_frequency'], - ylabels=electrode_labels, yscale=3.0) +stacklineplot.show_epoch_centered( + signals, + 1836, + epoch_width_sec=15, + chstart=0, + chstop=19, + fs=rec.attrs["sample_frequency"], + ylabels=electrode_labels, + yscale=3.0, +) # %% electrode_labels -r_labels = [ss.replace('EEG ','') for ss in electrode_labels] +r_labels = [ss.replace("EEG ", "") for ss in electrode_labels] r_labels # %% montageview.DB_LABELS # %% jupyter={"outputs_hidden": true} - diff --git a/notebooks/vizSpasm-montage-experiments.py b/notebooks/vizSpasm-montage-experiments.py index b49fc29..fe93f2b 100644 --- a/notebooks/vizSpasm-montage-experiments.py +++ b/notebooks/vizSpasm-montage-experiments.py @@ -30,7 +30,8 @@ import matplotlib import matplotlib.pyplot as plt -#import seaborn + +# import seaborn import pandas as pd import numpy as np import h5py @@ -39,23 +40,23 @@ import eegvis.stacklineplot as stacklineplot import eegvis.montageview as montageview -matplotlib.rcParams['figure.figsize'] = (18.0, 12.0) -#matplotlib.rcParams['figure.figsize'] = (12.0, 8.0) +matplotlib.rcParams["figure.figsize"] = (18.0, 12.0) +# matplotlib.rcParams['figure.figsize'] = (12.0, 8.0) # %% slideshow={"slide_type": "skip"} # ls "../../eeg-hdfstorage/data" # %% slideshow={"slide_type": "slide"} -hdf = h5py.File('../../eeg-hdfstorage/data/spasms.eeghdf') # 5mo boy +hdf = h5py.File("../../eeg-hdfstorage/data/spasms.eeghdf") # 5mo boy # %% slideshow={"slide_type": "skip"} hdf # %% slideshow={"slide_type": "slide"} -rec = hdf['record-0'] -years_old = rec.attrs['patient_age_days']/365 +rec = hdf["record-0"] +years_old = rec.attrs["patient_age_days"] / 365 pprint("age in years: %s" % years_old) @@ -64,10 +65,12 @@ # %% slideshow={"slide_type": "fragment"} -signals = rec['signals'] -labels = rec['signal_labels'] -electrode_labels = [str(s,'ascii') for s in labels] -numbered_electrode_labels = ["%d:%s" % (ii, str(labels[ii], 'ascii')) for ii in range(len(labels))] +signals = rec["signals"] +labels = rec["signal_labels"] +electrode_labels = [str(s, "ascii") for s in labels] +numbered_electrode_labels = [ + "%d:%s" % (ii, str(labels[ii], "ascii")) for ii in range(len(labels)) +] # %% [markdown] slideshow={"slide_type": "slide"} @@ -76,25 +79,31 @@ # %% slideshow={"slide_type": "fragment"} # plot 10s epochs (multiples in DE) -ch0, ch1 = (0,19) -DE = 2 # how many 10s epochs to display -epoch = 53; ptepoch = 10*int(rec.attrs['sample_frequency']) -dp = int(0.5*ptepoch) +ch0, ch1 = (0, 19) +DE = 2 # how many 10s epochs to display +epoch = 53 +ptepoch = 10 * int(rec.attrs["sample_frequency"]) +dp = int(0.5 * ptepoch) # stacklineplot.stackplot(signals[ch0:ch1,epoch*ptepoch+dp:(epoch+DE)*ptepoch+dp],secondsk=DE*10.0, ylabels=electrode_labels[ch0:ch1], yscale=0.3) print("epoch:", epoch) # %% -matplotlib.rcParams['figure.figsize'] = (18.0, 12.0) +matplotlib.rcParams["figure.figsize"] = (18.0, 12.0) # %% slideshow={"slide_type": "slide"} # search identified spasms at 1836, 1871, 1901, 1939 -stacklineplot.show_epoch_centered(signals, 1836, - epoch_width_sec=15, - chstart=0, chstop=19, fs=rec.attrs['sample_frequency'], - ylabels=electrode_labels, yscale=3.0) - +stacklineplot.show_epoch_centered( + signals, + 1836, + epoch_width_sec=15, + chstart=0, + chstop=19, + fs=rec.attrs["sample_frequency"], + ylabels=electrode_labels, + yscale=3.0, +) # %% slideshow={"slide_type": "slide"} @@ -109,37 +118,53 @@ v = montageview.double_banana_set_matrix(monv.V) v -dfv = v.to_dataframe(name='doublebanana') +dfv = v.to_dataframe(name="doublebanana") # %% -res = np.dot(monv.V.data,signals[:, 10000:10099]) # example of how to do transformation +res = np.dot( + monv.V.data, signals[:, 10000:10099] +) # example of how to do transformation signals.dtype # %% # access the coordinate labels in the xarray -[xx for xx in monv.V.coords['x'].data] +[xx for xx in monv.V.coords["x"].data] # %% -[yy for yy in monv.V.coords['y'].data] +[yy for yy in monv.V.coords["y"].data] # %% slideshow={"slide_type": "slide"} -stacklineplot.show_montage_centered(signals, monv,1836, - epoch_width_sec=15, - chstart=0, chstop=19, fs=rec.attrs['sample_frequency'], - ylabels=electrode_labels, yscale=3.0) +stacklineplot.show_montage_centered( + signals, + monv, + 1836, + epoch_width_sec=15, + chstart=0, + chstop=19, + fs=rec.attrs["sample_frequency"], + ylabels=electrode_labels, + yscale=3.0, +) # %% slideshow={"slide_type": "skip"} -stacklineplot.show_montage_centered(signals, monv,1836, - epoch_width_sec=15, - chstart=0, chstop=19, fs=rec.attrs['sample_frequency'], - ylabels=electrode_labels, yscale=3.0) +stacklineplot.show_montage_centered( + signals, + monv, + 1836, + epoch_width_sec=15, + chstart=0, + chstop=19, + fs=rec.attrs["sample_frequency"], + ylabels=electrode_labels, + yscale=3.0, +) fg = plt.gcf() -fg.savefig('spasm_example.eps') +fg.savefig("spasm_example.eps") # %% slideshow={"slide_type": "skip"} @@ -147,9 +172,9 @@ # %% slideshow={"slide_type": "skip"} -annot = rec['edf_annotations'] -#print(list(annot.items())) -#annot['texts'][:] +annot = rec["edf_annotations"] +# print(list(annot.items())) +# annot['texts'][:] # %% slideshow={"slide_type": "skip"} @@ -161,8 +186,8 @@ # %% slideshow={"slide_type": "skip"} -antext = [s.decode('utf-8') for s in annot['texts'][:]] -starts100ns = [xx for xx in annot['starts_100ns'][:]] +antext = [s.decode("utf-8") for s in annot["texts"][:]] +starts100ns = [xx for xx in annot["starts_100ns"][:]] len(starts100ns), len(antext) @@ -171,22 +196,22 @@ # %% slideshow={"slide_type": "fragment"} -df = pd.DataFrame(data=antext, columns=['text']) -df['starts100ns'] = starts100ns -df['starts_sec'] = df['starts100ns']/10**7 +df = pd.DataFrame(data=antext, columns=["text"]) +df["starts100ns"] = starts100ns +df["starts_sec"] = df["starts100ns"] / 10**7 # %% slideshow={"slide_type": "slide"} -df # look at the annotations +df # look at the annotations # %% slideshow={"slide_type": "skip"} -df[df.text.str.contains('sz',case=False)] +df[df.text.str.contains("sz", case=False)] # %% slideshow={"slide_type": "skip"} -df[df.text.str.contains('seizure',case=False)] # find the seizure +df[df.text.str.contains("seizure", case=False)] # find the seizure # %% slideshow={"slide_type": "slide"} -df[df.text.str.contains('spasm',case=False)] # find the seizure +df[df.text.str.contains("spasm", case=False)] # find the seizure # %% slideshow={"slide_type": "skip"} list(annot.items()) @@ -213,10 +238,18 @@ # %% slideshow={"slide_type": "skip"} -stacklineplot.show_montage_centered(signals, lapmv,1836, - epoch_width_sec=15, - chstart=0, chstop=19, fs=rec.attrs['sample_frequency'], - ylabels=electrode_labels, yscale=3.0, topdown=True) +stacklineplot.show_montage_centered( + signals, + lapmv, + 1836, + epoch_width_sec=15, + chstart=0, + chstop=19, + fs=rec.attrs["sample_frequency"], + ylabels=electrode_labels, + yscale=3.0, + topdown=True, +) fg = plt.gcf() @@ -228,8 +261,15 @@ # %% -stacklineplot.show_montage_centered(signals, tcpmv,1836, - epoch_width_sec=15, - chstart=0, chstop=19, fs=rec.attrs['sample_frequency'], - ylabels=electrode_labels, yscale=3.0, topdown=True) - +stacklineplot.show_montage_centered( + signals, + tcpmv, + 1836, + epoch_width_sec=15, + chstart=0, + chstop=19, + fs=rec.attrs["sample_frequency"], + ylabels=electrode_labels, + yscale=3.0, + topdown=True, +) diff --git a/setup-dev.py b/setup-dev.py index 50ae147..01bdec4 100644 --- a/setup-dev.py +++ b/setup-dev.py @@ -6,7 +6,6 @@ from distutils.core import setup - setup( name="eegvis", version="0.3.0", @@ -17,9 +16,13 @@ # download_url="http://bitbucket.org/cleemesser/eegvis/downloads", classifiers=["Topic :: Science :: EEG"], packages=["eegvis"], - install_requires = [ - 'xarray','ipywidgets >= 7.0', 'bokeh>=0.12.16', 'matplotlib', - 'eeghdf'], + install_requires=[ + "xarray", + "ipywidgets >= 7.0", + "bokeh>=0.12.16", + "matplotlib", + "eeghdf", + ], # package_data={} # data_files=[], # scripts = [], diff --git a/tests/test_stacklineplot.py b/tests/test_stacklineplot.py index 9f82372..0315aca 100644 --- a/tests/test_stacklineplot.py +++ b/tests/test_stacklineplot.py @@ -2,17 +2,18 @@ import matplotlib.pyplot as plt import eegvis.stacklineplot as stacklineplot + def make_calibration_signal(numSamples, levels=[0, 10, -10, 10, -10]): N = len(levels) interval = int(numSamples / N) - data = np.zeros(numSamples) + data = np.zeros(numSamples) for ii in range(N): data[ii * interval : (ii + 1) * interval] = levels[ii] return data + def test_add_data_vertical_scalebar(): - numSamples, numRows = 800, 5 data = np.random.randn(numRows, numSamples) # test data @@ -21,9 +22,9 @@ def test_add_data_vertical_scalebar(): fig, ax = plt.subplots() ax = stacklineplot.stackplot(data, ax=ax, ysensitivity=7) - stacklineplot.add_data_vertical_scalebar(ax=ax,units=r'$\mu$V') - #plt.show() - fig.savefig('test_add_vertical_data_scalebar.svg') + stacklineplot.add_data_vertical_scalebar(ax=ax, units=r"$\mu$V") + # plt.show() + fig.savefig("test_add_vertical_data_scalebar.svg") def test_add_relative_vertical_scalebar(): @@ -34,6 +35,6 @@ def test_add_relative_vertical_scalebar(): fig, ax = plt.subplots() ax = stacklineplot.stackplot(data, ax=ax) - stacklineplot.add_relative_vertical_scalebar(ax=ax,units=r'$\mu$V') - #plt.show() - fig.savefig('test_add_relative_vertical_scalebar.svg') + stacklineplot.add_relative_vertical_scalebar(ax=ax, units=r"$\mu$V") + # plt.show() + fig.savefig("test_add_relative_vertical_scalebar.svg") diff --git a/tests/use-commonref-montage.py b/tests/use-commonref-montage.py index 5f2be72..9af7967 100644 --- a/tests/use-commonref-montage.py +++ b/tests/use-commonref-montage.py @@ -18,17 +18,19 @@ # %% slideshow={"slide_type": "fragment"} # # %load explore-eeghdf-files-basics.py # Here is an example of how to do basic exploration of what is in the eeghdf file. I show how to discover the fields in the file and to plot them. -# +# # I have copied the stacklineplot from my python-edf/examples code to help with display. Maybe I will put this as a helper or put it out as a utility package to make it easier to install. from __future__ import print_function, division, unicode_literals import os.path + # %matplotlib inline # # %matplotlib notebook import pathlib import matplotlib import matplotlib.pyplot as plt -#import seaborn + +# import seaborn import pandas as pd import numpy as np import h5py @@ -39,60 +41,67 @@ import eegvis.montageview as montageview # matplotlib.rcParams['figure.figsize'] = (18.0, 12.0) -matplotlib.rcParams['figure.figsize'] = (12.0, 8.0) -ARCHIVEDIR = r'../../eeghdf/data' -EEGFILE = os.path.join(ARCHIVEDIR, 'spasms.eeghdf') +matplotlib.rcParams["figure.figsize"] = (12.0, 8.0) +ARCHIVEDIR = r"../../eeghdf/data" +EEGFILE = os.path.join(ARCHIVEDIR, "spasms.eeghdf") # %% # ls -l ../../eeghdf/data # %% slideshow={"slide_type": "slide"} -hdf = h5py.File(EEGFILE) +hdf = h5py.File(EEGFILE) pt = pathlib.Path(EEGFILE) pt.is_file() # %% slideshow={"slide_type": "slide"} -#hf = eeghdf.Eeghdf(EEGFILE) +# hf = eeghdf.Eeghdf(EEGFILE) hdf.keys() # %% slideshow={"slide_type": "slide"} -rec = hdf['record-0'] -years_old = rec.attrs['patient_age_days']/365 +rec = hdf["record-0"] +years_old = rec.attrs["patient_age_days"] / 365 pprint("age in years: %s" % years_old) # %% slideshow={"slide_type": "slide"} -signals = rec['signals'] -labels = rec['signal_labels'] -electrode_labels = [str(s,'ascii') for s in labels] -numbered_electrode_labels = ["%d:%s" % (ii, str(labels[ii], 'ascii')) for ii in range(len(labels))] +signals = rec["signals"] +labels = rec["signal_labels"] +electrode_labels = [str(s, "ascii") for s in labels] +numbered_electrode_labels = [ + "%d:%s" % (ii, str(labels[ii], "ascii")) for ii in range(len(labels)) +] # %% [markdown] slideshow={"slide_type": "slide"} # #### Simple visualization of EEG (electrodecrement seizure pattern) # %% slideshow={"slide_type": "fragment"} # plot 10s epochs (multiples in DE) -ch0, ch1 = (0,19) -DE = 2 # how many 10s epochs to display -epoch = 53; ptepoch = 10*int(rec.attrs['sample_frequency']) -dp = int(0.5*ptepoch) +ch0, ch1 = (0, 19) +DE = 2 # how many 10s epochs to display +epoch = 53 +ptepoch = 10 * int(rec.attrs["sample_frequency"]) +dp = int(0.5 * ptepoch) # stacklineplot.stackplot(signals[ch0:ch1,epoch*ptepoch+dp:(epoch+DE)*ptepoch+dp],seconds=DE*10.0, ylabels=electrode_labels[ch0:ch1], yscale=0.3) print("epoch:", epoch) - # %% # search identified spasms at 1836, 1871, 1901, 1939 -stacklineplot.show_epoch_centered(signals, 1836, - epoch_width_sec=15, - chstart=0, chstop=19, fs=rec.attrs['sample_frequency'], - ylabels=electrode_labels, yscale=3.0) - +stacklineplot.show_epoch_centered( + signals, + 1836, + epoch_width_sec=15, + chstart=0, + chstop=19, + fs=rec.attrs["sample_frequency"], + ylabels=electrode_labels, + yscale=3.0, +) # %% electrode_labels -#r_labels = [ss.replace('EEG ','') for ss in electrode_labels] +# r_labels = [ss.replace('EEG ','') for ss in electrode_labels] # r_labels ref_labels = montageview.standard2shortname(electrode_labels) ref_labels @@ -101,17 +110,22 @@ montageview.DB_LABELS # %% -db_ref = montageview.MONTAGE_BUILTINS['DB-REF'](ref_labels) -#odict_keys(['trace', 'tcp', 'double banana', 'laplacian', 'neonatal', 'DB-REF']) +db_ref = montageview.MONTAGE_BUILTINS["DB-REF"](ref_labels) +# odict_keys(['trace', 'tcp', 'double banana', 'laplacian', 'neonatal', 'DB-REF']) # %% # %% -stacklineplot.show_montage_centered(signals, # numpy array-like (channel, samplenum) shape - db_ref, 1836, - epoch_width_sec=15, - chstart=0, chstop=19, fs=rec.attrs['sample_frequency'], - yscale=3.0) +stacklineplot.show_montage_centered( + signals, # numpy array-like (channel, samplenum) shape + db_ref, + 1836, + epoch_width_sec=15, + chstart=0, + chstop=19, + fs=rec.attrs["sample_frequency"], + yscale=3.0, +) # %% # stacklineplot.show_montage_centered? From 905ed0a4ca0db6f3a69385cf794910f5de0a6dc0 Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Mon, 11 May 2026 01:46:11 -0700 Subject: [PATCH 13/17] added no fixed aspect ratio for viewer so can use more of page - starting to get to an acceptable viewer --- CLAUDE.md | 66 ++++++++++++++++++++++++++++++++++--- eegvis/stackplot_svg.py | 33 +++++++++++-------- eegvis/viewer/app.py | 1 + eegvis/viewer/components.py | 21 ++++++++---- 4 files changed, 97 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 70cb8a1..e7554dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,7 @@ to support the hypermedia viewer (see `eegvis/viewer/`). # Run tests with pytest pytest tests/ pytest tests/test_stackplot_svg.py # SVG backend tests +pytest tests/test_montage_display.py # MontageDisplay / bundled profiles # Run via tox (multiple Python/matplotlib versions) tox @@ -40,6 +41,9 @@ tox -e py310 tox -e py37-mpl3.2 # tests against matplotlib 3.2 (Colab compatibility) ``` +A local pre-commit hook in `.git/hooks/pre-commit` runs `uvx ruff format` on +staged `.py` files and re-stages them. Bypass with `--no-verify`. + ## Architecture ### Visualization Backends (parallel implementations) @@ -61,11 +65,29 @@ The SVG backend is structured as a pipeline of pure functions: `vector-effect="non-scaling-stroke"` so line weight stays constant under zoom. Includes vertical time grid, scale bar (µV by default), border, channel labels, and an empty `` layer for downstream - viewer overlays. + viewer overlays. Extra knobs: + - `theme=` picks a `SvgTheme` (background, grid, trace, label colors and + weights). Bundled themes: `DEFAULT_THEME`, `PUBLICATION_THEME`, + `STRATUS_THEME` (color-cycles traces in `color_group_size`-channel bands). + - `channel_gaps_mm=` is a per-channel sequence of extra vertical mm to + insert *after* each channel — used for inter-group spacers. + - `channel_colors=` is a per-channel sequence of explicit color overrides + that wins over the theme's group-cycled color. + - `preserve_aspect_ratio=` is written verbatim to the SVG root attribute + (default: omit). Set to `"none"` to let the SVG stretch independently + in x and y to fill its container — used by the viewer to spread + waveforms edge-to-edge. - **`save_svg(filepath, ...)`** — Writes the SVG string to disk. - **`show_montage_svg` / `save_montage_svg`** — Convenience wrappers that apply a `MontageView.V.data` matrix multiply before rendering and use `montage.montage_labels` as channel labels. +- **`show_montage_display_svg(signals, sample_frequency, display, rec_labels=, ...)` / + `save_montage_display_svg`** — Render using a `MontageDisplay` profile + (see below). Honors the display's group order, per-group/per-channel + colors, gaps, gains, and visibility. Flips channel order before calling + `stackplot_svg` so that profiles can keep the clinical "first listed = + top of page" convention while the underlying renderer numbers channels + bottom-up. - **`eeg_to_svg(signals, sample_frequency, montage=, low_freq=, high_freq=, notch_freq=, target_frequency=, max_samples_per_channel=, ...)`** — End-to-end pipeline: downsample → montage derivation → bandpass → notch → render. - **`save_eeg_svg(filepath, ...)`** — File-output sibling of `eeg_to_svg`. @@ -86,6 +108,32 @@ and by annotation tooling. high-sample-rate recordings — it triggers a one-shot decimation tuned so each polyline has at most that many points. +### Montage Display Profiles (`montage_display.py` + `displays/`) + +A `MontageDisplay` bundles three things that historically lived in separate +places: the derivation (matrix or built-in name), the channel grouping/order, +and per-channel style overrides. It is the canonical way to author a +clinical layout that wants per-hemisphere coloring or visual spacers between +chains. + +- **`MontageDisplay`** carries `derivation_ref` (a name like `"double_banana"` + resolved against `_BUILTIN_DERIVATIONS`) or a self-contained + `MontageDerivation(matrix, montage_labels, rec_labels)`, a list of + `ChannelGroup` (name, channels, default color, `gap_after_mm`), and a dict + of per-channel `ChannelStyle` overrides (color, gain, gap, visibility). +- **Order convention**: channels listed earlier in the file are drawn + *higher* on the page. `gap_after_mm` inserts space *below* that group or + channel. +- **`save() / load()`** serialize a profile to / from JSON. +- **`eegvis/displays/`** ships JSON profiles next to the package. Use + `from eegvis.displays import list_displays, load_display`. Currently + bundled: + - `double_banana.json` — clinical "left chains | midline | right chains" + layout, blue/black/red coloring, with two large spacers at the left↔right + transitions around the midline. + - `double_banana_paired.json` — LT/RT, LL/RR stacked layout with spacers + between every left/right hand-off. + ### Hypermedia Clinical Viewer (`eegvis/viewer/`) A FastAPI app that renders SVG pages of EEG and streams updates via datastar @@ -108,7 +156,11 @@ patches. - **`viewer/components.py`** — ztml HTML components for the page shell, toolbar (montage / sensitivity / page-duration / nav / filter selects), display area, jump bar, and status bar. Uses datastar `data-on:*` and `data-bind` - attributes; loads the datastar runtime from the jsDelivr CDN. + attributes; loads the datastar runtime from the jsDelivr CDN. Layout is a + fixed-height (`100vh`) flex column: toolbar / jump-bar / status-bar take + their natural size; `.display-area` flex-grows to fill the rest and the + SVG inside is rendered with `preserveAspectRatio="none"` so the waveforms + span edge-to-edge horizontally. Run the demo with synthetic data: @@ -145,7 +197,13 @@ noise, and 60 Hz line noise so the viewer is exercisable without real data. - Montage derivations are expressed as xarray matrix operations over ordered channel dictionaries. - Clinical "negative up" display: in the SVG backend this falls out of SVG's native y-down axis — raw data values map directly without sign flip. -- SVG layout coordinates are in millimeters via `viewBox`; CSS scales the SVG - to container width while preserving aspect ratio. +- SVG layout coordinates are in millimeters via `viewBox`. Standalone exports + rely on the default `preserveAspectRatio="xMidYMid meet"` (letterbox to + preserve aspect for print); the viewer overrides this to `"none"` to fill + the available container in both dimensions. +- Channel ordering: `stackplot_svg` numbers channels bottom-up (index 0 at + the bottom), but `MontageDisplay` and its `show_montage_display_svg` + wrapper expose the inverse convention (first listed = top of page). + Author profiles in clinical top-down order. - Python 3.7+ required (f-strings). Some files retain `__future__` imports for historical compatibility. - matplotlib >=3.2 is supported via backported `AffineDeltaTransform`. diff --git a/eegvis/stackplot_svg.py b/eegvis/stackplot_svg.py index 5296a89..1b99fa2 100644 --- a/eegvis/stackplot_svg.py +++ b/eegvis/stackplot_svg.py @@ -313,6 +313,7 @@ def stackplot_svg( color_group_size=4, channel_gaps_mm=None, channel_colors=None, + preserve_aspect_ratio=None, ): """Generate an SVG string of stacked EEG traces. @@ -357,6 +358,12 @@ def stackplot_svg( (length must equal num_channels). An entry of None falls back to the theme's group-cycled color. Channels are matched by their original index in the signals array, not by display order. + preserve_aspect_ratio: optional value for the SVG root's + ``preserveAspectRatio`` attribute. Leave as None (default) to + omit the attribute and rely on the SVG default of + "xMidYMid meet" (letterbox to preserve aspect). Set to "none" + for the SVG to stretch independently in x and y to fill its + container — useful for full-page strip-chart viewers. Returns: SVG content as a string @@ -467,19 +474,19 @@ def data_y_to_svg(y_data): label_order = list(reversed(label_order)) # build SVG - svg = ET.Element( - "svg", - { - "xmlns": SVG_NS, - "viewBox": f"0 0 {width_mm} {height_mm}", - "width": f"{width_mm}mm", - "height": f"{height_mm}mm", - "data-sample-frequency": str(sample_frequency), - "data-start-time": str(start_time), - "data-seconds": str(seconds), - "data-num-channels": str(num_channels), - }, - ) + svg_attrs = { + "xmlns": SVG_NS, + "viewBox": f"0 0 {width_mm} {height_mm}", + "width": f"{width_mm}mm", + "height": f"{height_mm}mm", + "data-sample-frequency": str(sample_frequency), + "data-start-time": str(start_time), + "data-seconds": str(seconds), + "data-num-channels": str(num_channels), + } + if preserve_aspect_ratio is not None: + svg_attrs["preserveAspectRatio"] = preserve_aspect_ratio + svg = ET.Element("svg", svg_attrs) # background ET.SubElement( diff --git a/eegvis/viewer/app.py b/eegvis/viewer/app.py index 284542a..32ab2e1 100644 --- a/eegvis/viewer/app.py +++ b/eegvis/viewer/app.py @@ -87,6 +87,7 @@ def _render_page_svg(study: "_StudyData", session: ViewerSession) -> str: sensitivity=session.sensitivity, show_scalebar=True, max_samples_per_channel=2000, + preserve_aspect_ratio="none", ) diff --git a/eegvis/viewer/components.py b/eegvis/viewer/components.py index d499bc7..c11bc7b 100644 --- a/eegvis/viewer/components.py +++ b/eegvis/viewer/components.py @@ -71,12 +71,16 @@ def _viewer_styles(): return Style( RawCss(""" * { box-sizing: border-box; margin: 0; padding: 0; } + html, body { height: 100%; } body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; } - .viewer-root { max-width: 1400px; margin: 0 auto; padding: 8px; } + .viewer-root { + max-width: 1400px; margin: 0 auto; padding: 8px; + height: 100vh; display: flex; flex-direction: column; gap: 8px; + } .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; padding: 8px; background: white; border: 1px solid #ddd; - border-radius: 4px; margin-bottom: 8px; + border-radius: 4px; } .toolbar label { font-size: 13px; color: #555; } .toolbar select, .toolbar input, .toolbar button { @@ -89,18 +93,21 @@ def _viewer_styles(): .display-area { background: white; border: 1px solid #ddd; border-radius: 4px; padding: 4px; overflow: hidden; + flex: 1 1 auto; min-height: 0; display: flex; + } + .display-area svg { + width: 100%; height: 100%; flex: 1 1 auto; min-height: 0; } - .display-area svg { width: 100%; height: auto; } .status-bar { display: flex; justify-content: space-between; align-items: center; padding: 4px 8px; font-size: 12px; color: #777; - background: white; border: 1px solid #ddd; - border-radius: 4px; margin-top: 8px; + background: white; border: 1px solid #ddd; border-radius: 4px; + flex: 0 0 auto; } .jump-bar { height: 24px; background: #eee; border: 1px solid #ddd; - border-radius: 3px; margin-top: 8px; position: relative; - cursor: pointer; + border-radius: 3px; position: relative; cursor: pointer; + flex: 0 0 auto; } .jump-bar .position-marker { position: absolute; top: 0; height: 100%; From 7655ef034c291de11ea0c095fd7d5232f6a91fb7 Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Mon, 11 May 2026 02:15:35 -0700 Subject: [PATCH 14/17] fix neonatal misesed channel, add more montage derivations --- eegvis/montageview.py | 114 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 100 insertions(+), 14 deletions(-) diff --git a/eegvis/montageview.py b/eegvis/montageview.py index c498076..5da35b2 100644 --- a/eegvis/montageview.py +++ b/eegvis/montageview.py @@ -618,6 +618,96 @@ def tcp_set_matrix(self, V): V.loc["C4-P4", "P4"] = -1 +class CircumferentialMontageView(MontageView): + """Circumferential ("circle") montage that traces the head perimeter. + + The chain runs counter-clockwise starting at Fp1: down the left side + (Fp1 -> F7 -> T3 -> T5 -> O1), across the back (O1 -> O2), up the + right side (O2 -> T6 -> T4 -> F8 -> Fp2), and across the front + (Fp2 -> Fp1) to close the loop. Useful for picking up phase reversals + that wrap around the edge of the scalp. + """ + + CIRCLE_LABELS = [ + "Fp1-F7", + "F7-T3", + "T3-T5", + "T5-O1", + "O1-O2", + "O2-T6", + "T6-T4", + "T4-F8", + "F8-Fp2", + "Fp2-Fp1", + ] + + def __init__(self, rec_labels, reversed_polarity=True): + super().__init__( + self.CIRCLE_LABELS, rec_labels, reversed_polarity=reversed_polarity + ) + V = self.V + for pair in self.CIRCLE_LABELS: + a, b = pair.split("-") + V.loc[pair, a] = 1 + V.loc[pair, b] = -1 + if reversed_polarity: + self.V = (-1) * self.V + self.name = "circle" + self.full_name = "%s, up=%s" % (self.name, POSCHOICE[reversed_polarity]) + + +class TrueSphenoidalMontageView(MontageView): + """True sphenoidal montage: threads the surgically placed sphenoidal + electrodes (Sp1, Sp2) into the anterior temporal chains. + + "True" here distinguishes this from anterior-temporal proxy montages + (e.g. using FT9/FT10 or T1/T2 surface positions): this derivation + references the actual sphenoidal needle electrodes that sit near the + foramen ovale, so it requires those leads to be in ``rec_labels``. + + Replaces the standard F7-T3 / F8-T4 links with F7-Sp1, Sp1-T3 (and + mirror), pulling mesial-temporal activity into view. The remaining + parasagittal and midline chains are unchanged from double banana. + """ + + SPHENOIDAL_LABELS = [ + "Fp1-F7", + "F7-Sp1", + "Sp1-T3", + "T3-T5", + "T5-O1", + "Fp2-F8", + "F8-Sp2", + "Sp2-T4", + "T4-T6", + "T6-O2", + "Fp1-F3", + "F3-C3", + "C3-P3", + "P3-O1", + "Fp2-F4", + "F4-C4", + "C4-P4", + "P4-O2", + "Fz-Cz", + "Cz-Pz", + ] + + def __init__(self, rec_labels, reversed_polarity=True): + super().__init__( + self.SPHENOIDAL_LABELS, rec_labels, reversed_polarity=reversed_polarity + ) + V = self.V + for pair in self.SPHENOIDAL_LABELS: + a, b = pair.split("-") + V.loc[pair, a] = 1 + V.loc[pair, b] = -1 + if reversed_polarity: + self.V = (-1) * self.V + self.name = "true sphenoidal" + self.full_name = "%s, up=%s" % (self.name, POSCHOICE[reversed_polarity]) + + ### A Neonatal montage (modified 10-20) class NeonatalMontageView(MontageView): """ @@ -676,26 +766,25 @@ def __init__(self, rec_labels, reversed_polarity=True): self.full_name = "%s, up=%s" % (self.name, POSCHOICE[reversed_polarity]) def neonatal_set_matrix(self, V): - # pdb.set_trace() - V.loc["Fp1-T3", "FP1"] = 1 + V.loc["Fp1-T3", "Fp1"] = 1 V.loc["Fp1-T3", "T3"] = -1 V.loc["T3-O1", "T3"] = 1 V.loc["T3-O1", "O1"] = -1 - V.loc["Fp2-T4", "FP2"] = 1 + V.loc["Fp2-T4", "Fp2"] = 1 V.loc["Fp2-T4", "T4"] = -1 V.loc["T4-O2", "T4"] = 1 V.loc["T4-O2", "O2"] = -1 - V.loc["Fp1-C3", "FP1"] = 1 + V.loc["Fp1-C3", "Fp1"] = 1 V.loc["Fp1-C3", "C3"] = -1 V.loc["C3-O1", "C3"] = 1 V.loc["C3-O1", "O1"] = -1 - V.loc["Fp2-C4", "FP2"] = 1 + V.loc["Fp2-C4", "Fp2"] = 1 V.loc["Fp2-C4", "C4"] = -1 V.loc["C4-O2", "C4"] = 1 @@ -705,22 +794,19 @@ def neonatal_set_matrix(self, V): V.loc["T3-C3", "C3"] = -1 V.loc["C3-Cz", "C3"] = 1 - V.loc["C3-Cz", "CZ"] = -1 + V.loc["C3-Cz", "Cz"] = -1 - V.loc["Cz-C4", "CZ"] = 1 + V.loc["Cz-C4", "Cz"] = 1 V.loc["Cz-C4", "C4"] = -1 - V.loc["C4-T4", "CZ"] = 1 - V.loc["C4-T4", "T4"] = -1 - V.loc["C4-T4", "C4"] = 1 V.loc["C4-T4", "T4"] = -1 - V.loc["Fz-Cz", "FZ"] = 1 - V.loc["Fz-Cz", "CZ"] = -1 + V.loc["Fz-Cz", "Fz"] = 1 + V.loc["Fz-Cz", "Cz"] = -1 - V.loc["T3-O1", "T3"] = 1 - V.loc["T3-O1", "O1"] = -1 + V.loc["Cz-Pz", "Cz"] = 1 + V.loc["Cz-Pz", "Pz"] = -1 V.loc["O1-O2", "O1"] = 1 V.loc["O1-O2", "O2"] = -1 From 36905c1b8dc4555da02d305a08939a516b3e93c7 Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Thu, 14 May 2026 15:21:07 -0700 Subject: [PATCH 15/17] adding montage editor --- CLAUDE.md | 72 ++- docs/eeg-references.md | 3 + eegvis/viewer/app.py | 207 ++++++++- pyproject.toml | 1 + tests/test_montage_display.py | 834 ++++++++++++++++++++++++++++++++++ 5 files changed, 1093 insertions(+), 24 deletions(-) create mode 100644 docs/eeg-references.md create mode 100644 tests/test_montage_display.py diff --git a/CLAUDE.md b/CLAUDE.md index e7554dd..8bc7b4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,28 +111,56 @@ polyline has at most that many points. ### Montage Display Profiles (`montage_display.py` + `displays/`) A `MontageDisplay` bundles three things that historically lived in separate -places: the derivation (matrix or built-in name), the channel grouping/order, -and per-channel style overrides. It is the canonical way to author a -clinical layout that wants per-hemisphere coloring or visual spacers between -chains. - -- **`MontageDisplay`** carries `derivation_ref` (a name like `"double_banana"` - resolved against `_BUILTIN_DERIVATIONS`) or a self-contained - `MontageDerivation(matrix, montage_labels, rec_labels)`, a list of - `ChannelGroup` (name, channels, default color, `gap_after_mm`), and a dict - of per-channel `ChannelStyle` overrides (color, gain, gap, visibility). -- **Order convention**: channels listed earlier in the file are drawn - *higher* on the page. `gap_after_mm` inserts space *below* that group or - channel. -- **`save() / load()`** serialize a profile to / from JSON. -- **`eegvis/displays/`** ships JSON profiles next to the package. Use - `from eegvis.displays import list_displays, load_display`. Currently - bundled: - - `double_banana.json` — clinical "left chains | midline | right chains" - layout, blue/black/red coloring, with two large spacers at the left↔right - transitions around the midline. - - `double_banana_paired.json` — LT/RT, LL/RR stacked layout with spacers - between every left/right hand-off. +places: the derivation, the channel grouping/order, and per-channel style +overrides. It is the canonical way to author a clinical layout that wants +per-hemisphere coloring or visual spacers between chains. + +The JSON file format is formally specified in +**`eegvis/displays/SCHEMA.md`** — that doc is the source of truth for what +fields exist and how they are resolved. + +Three derivation forms exist (in order of preference for new profiles): + +1. **`SymbolicDerivation`** (`"type": "symbolic"`) — portable. Channels are + declared as symbolic linear combinations: `{"label": "Fp1-F7", + "diffpair": ["Fp1", "F7"]}` for the bipolar shorthand, or + `{"label": "x", "sum_coefficients": {"A": 0.5, "B": -0.5}}` for general + weighted sums. Reference resolution looks up names first in + `virtual_channels` (named linear combinations of physical electrodes — + `"AVG"`, `"LE"`, etc.) then in `rec_labels`. The matrix is built at + apply time, so the same JSON works across recordings with different + electrode sets. +2. **`MontageDerivation`** (`"type": "matrix"`) — self-contained but locked + to a specific `rec_labels` ordering. Useful for fully self-contained + exports. +3. **`derivation_ref`** — names a built-in `MontageView` subclass + (`"double_banana"`, `"true_sphenoidal"`, etc.). Kept for back-compat; + the bundled profiles have moved to symbolic. + +Other elements: + +- **`ChannelGroup`**: name, channels (top-down order), default color, + `gap_after_mm` (extra space *below* the group's last visible channel). +- **`ChannelStyle`** (per-channel overrides): color, gain, gap, visibility. + +**Order convention**: channels listed earlier are drawn *higher* on the +page. `gap_after_mm` inserts space *below* that group / channel. + +**`save() / load()`** serialize a profile to / from JSON. + +**`eegvis/displays/`** ships JSON profiles next to the package. Use +`from eegvis.displays import list_displays, load_display`. Bundled +profiles (all symbolic): + +- `circle.json` — circumferential perimeter walk (10 channels). +- `double_banana.json` — clinical "left chains | midline | right chains". +- `double_banana_paired.json` — LT/RT, LL/RR stacked layout. +- `double_banana_avg.json` — common-average reference; uses the `AVG` + virtual channel. +- `tcp.json` — Temporal Central Parasagittal (20 channels). +- `neonatal.json` — modified 10-20 for neonates (16 channels). +- `true_sphenoidal.json` — double-banana variant threading Sp1/Sp2 needle + electrodes into the anterior temporal chains. ### Hypermedia Clinical Viewer (`eegvis/viewer/`) diff --git a/docs/eeg-references.md b/docs/eeg-references.md new file mode 100644 index 0000000..4d895aa --- /dev/null +++ b/docs/eeg-references.md @@ -0,0 +1,3 @@ +### montage derivations + +[MONTAGES FOR NONINVASIVE EEG RECORDING] (https://pmc.ncbi.nlm.nih.gov/articles/PMC6733527/) diff --git a/eegvis/viewer/app.py b/eegvis/viewer/app.py index 32ab2e1..3659a08 100644 --- a/eegvis/viewer/app.py +++ b/eegvis/viewer/app.py @@ -12,9 +12,22 @@ from datastar_py.fastapi import DatastarResponse from ztml import render -from ..stackplot_svg import stackplot_svg, bandpass_filter, notch_filter, downsample +from ..displays import ( + list_displays_by_scope, + load_display, + save_user_display, +) +from ..stackplot_svg import ( + bandpass_filter, + downsample, + notch_filter, + show_montage_display_svg, + stackplot_svg, +) +from .components import _display_area, _jump_bar, _status_bar, viewer_page +from .editor_components import editor_page, render_tbody_patch +from .editor_session import EditorSession from .session import SessionStore, ViewerSession -from .components import viewer_page, _display_area, _jump_bar, _status_bar app = FastAPI(title="EEG Viewer") @@ -281,6 +294,196 @@ async def set_filter( return DatastarResponse(_make_update_events(study, session)) +# -------------------------------------------------------------------------- +# MontageDisplay editor +# -------------------------------------------------------------------------- + +_editor_sessions: dict[str, EditorSession] = {} + + +def _get_or_create_editor_session(session_id: str | None) -> EditorSession: + if session_id and session_id in _editor_sessions: + return _editor_sessions[session_id] + s = EditorSession() + _editor_sessions[s.session_id] = s + return s + + +def _editor_preview_svg(session: EditorSession, study_id: str = "demo") -> str: + """Render a preview of the in-progress montage against the loaded study.""" + study = _studies.get(study_id) + if study is None: + return "" + display = session.to_display() + if not display.groups or not any(g.channels for g in display.groups): + return ( + '' + '' + "Add at least one channel to see preview" + "" + ) + fs = study.sample_frequency + n_secs = 10.0 + page = study.signals[:, : int(n_secs * fs)] + try: + return show_montage_display_svg( + page, + fs, + display, + rec_labels=study.channel_labels, + seconds=n_secs, + sensitivity=10.0, + width_mm=300, + height_mm=180, + preserve_aspect_ratio="none", + max_samples_per_channel=2000, + ) + except (KeyError, ValueError) as e: + return ( + f'' + f'' + f"preview error: {str(e)[:120]}" + f"" + ) + + +def _editor_patch_events(session: EditorSession): + """SSE events to refresh the tbody and the preview.""" + return [ + SSE.patch_elements( + render_tbody_patch(session), selector="#editor-tbody", mode="outer" + ), + SSE.patch_elements( + f'
{_editor_preview_svg(session)}
', + selector="#editor-preview", + mode="outer", + ), + ] + + +@app.get("/editor", response_class=HTMLResponse) +async def editor_index(session_id: str | None = Query(None)): + session = _get_or_create_editor_session(session_id) + profile_options = list_displays_by_scope() + html = render(editor_page(session, profile_options, _editor_preview_svg(session))) + return HTMLResponse(html) + + +@app.post("/editor/api/load") +async def editor_load( + session_id: str = Query(...), + scoped: str = Query(""), +): + """Load a bundled or user profile into the editor session. + + ``scoped`` has the form ``"system:"`` or ``"user:"``; empty + starts a blank profile. + """ + session = _get_or_create_editor_session(session_id) + if scoped: + scope, _, name = scoped.partition(":") + try: + display = load_display(name, scope=scope or None) + new_session = EditorSession.from_display(display) + new_session.session_id = session.session_id + _editor_sessions[session.session_id] = new_session + session = new_session + except KeyError: + pass + else: + # blank + new_session = EditorSession(session_id=session.session_id) + _editor_sessions[session.session_id] = new_session + session = new_session + events = _editor_patch_events(session) + events.append( + SSE.patch_elements( + f'", + selector="#editor-name", + mode="outer", + ) + ) + return DatastarResponse(events) + + +@app.post("/editor/api/rename") +async def editor_rename(session_id: str = Query(...), name: str = Query(...)): + session = _get_or_create_editor_session(session_id) + session.name = name + return DatastarResponse() + + +@app.post("/editor/api/insert_channel") +async def editor_insert_channel(session_id: str = Query(...)): + session = _get_or_create_editor_session(session_id) + session.insert_channel() + return DatastarResponse(_editor_patch_events(session)) + + +@app.post("/editor/api/insert_separator") +async def editor_insert_separator(session_id: str = Query(...)): + session = _get_or_create_editor_session(session_id) + session.insert_separator() + return DatastarResponse(_editor_patch_events(session)) + + +@app.post("/editor/api/delete_row") +async def editor_delete_row(session_id: str = Query(...)): + session = _get_or_create_editor_session(session_id) + session.delete_row() + return DatastarResponse(_editor_patch_events(session)) + + +@app.post("/editor/api/select") +async def editor_select(session_id: str = Query(...), row: int = Query(...)): + session = _get_or_create_editor_session(session_id) + session.selected_row = max(0, min(row, len(session.rows) - 1)) + return DatastarResponse(_editor_patch_events(session)) + + +@app.post("/editor/api/set_cell") +async def editor_set_cell( + session_id: str = Query(...), + row: int = Query(...), + field: str = Query(...), + value: str = Query(""), +): + session = _get_or_create_editor_session(session_id) + session.set_cell(row, field, value) + return DatastarResponse(_editor_patch_events(session)) + + +@app.post("/editor/api/save") +async def editor_save(session_id: str = Query(...)): + session = _get_or_create_editor_session(session_id) + display = session.to_display() + try: + path = save_user_display(display) + except ValueError as e: + return DatastarResponse( + [ + SSE.patch_elements( + f'
' + f'

save failed: {e}

', + selector="#editor-preview", + mode="outer", + ), + ] + ) + return DatastarResponse( + [ + SSE.patch_elements( + f'
' + f"

saved to {path}

" + f"{_editor_preview_svg(session)}
", + selector="#editor-preview", + mode="outer", + ), + ] + ) + + def create_demo_study(): """Create a synthetic EEG study for demo/testing.""" fs = 256.0 diff --git a/pyproject.toml b/pyproject.toml index e910b6c..a27255b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "ztml>=0.2.4", "datastar-py>=0.8.0", "fastapi>=0.135.2", + "jsonschema>=4", # MontageDisplay JSON profile validation ] # may want to break this out into different backends diff --git a/tests/test_montage_display.py b/tests/test_montage_display.py new file mode 100644 index 0000000..231b968 --- /dev/null +++ b/tests/test_montage_display.py @@ -0,0 +1,834 @@ +"""Tests for MontageDisplay (derivation + spacing + colors) and the related +gap/color extensions in stackplot_svg.""" + +import json +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +import numpy as np +import pytest + +import eegvis.stackplot_svg as stackplot_svg +from eegvis.montage_display import ( + ChannelGroup, + ChannelStyle, + MontageDerivation, + MontageDisplay, + SymbolicChannel, + SymbolicDerivation, + VirtualElectrode, + default_double_banana_display, + list_builtin_derivations, +) +from eegvis.stackplot_svg import ( + _compute_channel_offsets, + save_montage_display_svg, + show_montage_display_svg, +) + + +SVG_NS = stackplot_svg.SVG_NS +NS = {"svg": SVG_NS} + + +# ---------- _compute_channel_offsets with gap_after_mm ---------- + + +def test_gap_offsets_sensitivity_mode_shifts_later_channels(): + """In sensitivity mode, a gap after channel 0 should push channel 1+ further down.""" + data = np.zeros((100, 4)) + ticklocs_no_gap, _ = _compute_channel_offsets( + data, num_channels=4, sensitivity=7.0, height=200 + ) + gaps = [5.0, 0.0, 0.0, 0.0] + ticklocs_with_gap, _ = _compute_channel_offsets( + data, num_channels=4, sensitivity=7.0, height=200, gap_after_mm=gaps + ) + # Channel 0 stays near top; channels 1..3 should be shifted by 5mm*7 = 35 data units + # (modulo the smaller dr from squeezing into the remaining height). + assert ticklocs_with_gap[1] > ticklocs_no_gap[1] + assert ticklocs_with_gap[2] > ticklocs_no_gap[2] + # The gap separation between ch0 and ch1 should be larger than the + # nominal channel spacing. + spacing_01 = ticklocs_with_gap[1] - ticklocs_with_gap[0] + spacing_12 = ticklocs_with_gap[2] - ticklocs_with_gap[1] + assert spacing_01 > spacing_12 + + +def test_gap_offsets_auto_mode(): + """Auto mode should also widen the gap between channels.""" + data = np.random.RandomState(0).randn(50, 3) * 10.0 + ticklocs_no_gap, dr_no = _compute_channel_offsets( + data, num_channels=3, sensitivity=None, height=100 + ) + ticklocs_with_gap, dr_yes = _compute_channel_offsets( + data, num_channels=3, sensitivity=None, height=100, gap_after_mm=[3.0, 0.0, 0.0] + ) + # dr is data-driven and unaffected by gaps in auto mode + assert dr_no == dr_yes + # ch1 and ch2 should be further down with the gap applied + assert ticklocs_with_gap[1] > ticklocs_no_gap[1] + + +def test_gap_after_mm_wrong_length_raises(): + data = np.zeros((10, 3)) + with pytest.raises(ValueError): + _compute_channel_offsets( + data, num_channels=3, sensitivity=7.0, height=100, gap_after_mm=[1.0, 2.0] + ) + + +# ---------- stackplot_svg with channel_gaps_mm and channel_colors ---------- + + +def test_stackplot_svg_with_channel_gaps_renders(): + signals = np.random.RandomState(1).randn(4, 400) + svg_str = stackplot_svg.stackplot_svg( + signals, + sample_frequency=200.0, + seconds=2.0, + sensitivity=7.0, + channel_gaps_mm=[0.0, 5.0, 0.0, 0.0], + ) + root = ET.fromstring(svg_str) + channels = root.findall(".//svg:g[@class='channel']", NS) + assert len(channels) == 4 + + +def _channel_y(group_elem): + """Return the baseline y offset from a channel 's transform.""" + return float(group_elem.attrib["data-baseline"]) + + +def test_channel_gaps_increase_visual_separation(): + """A gap after channel 0 should put a larger pixel gap between rendered ch 0 and ch 1.""" + signals = np.zeros((4, 200)) + common = dict(sample_frequency=200.0, seconds=1.0, sensitivity=7.0, topdown=False) + svg_no = stackplot_svg.stackplot_svg(signals, **common) + svg_yes = stackplot_svg.stackplot_svg( + signals, channel_gaps_mm=[10.0, 0.0, 0.0, 0.0], **common + ) + chs_no = ET.fromstring(svg_no).findall(".//svg:g[@class='channel']", NS) + chs_yes = ET.fromstring(svg_yes).findall(".//svg:g[@class='channel']", NS) + # topdown=False means ch_idx 0 is drawn first (top), so consecutive + # elements correspond to consecutive channels. + sep_no = _channel_y(chs_no[1]) - _channel_y(chs_no[0]) + sep_yes = _channel_y(chs_yes[1]) - _channel_y(chs_yes[0]) + assert sep_yes > sep_no + + +def test_channel_colors_override_theme(): + signals = np.random.RandomState(2).randn(3, 200) + colors = ["#ff0000", None, "#00ff00"] + svg_str = stackplot_svg.stackplot_svg( + signals, + sample_frequency=200.0, + seconds=1.0, + channel_colors=colors, + topdown=False, + ) + root = ET.fromstring(svg_str) + polys = root.findall(".//svg:polyline[@class='trace']", NS) + assert len(polys) == 3 + # channel ordering: with topdown=False, draw order matches channel index + strokes = [p.attrib["stroke"] for p in polys] + assert strokes[0] == "#ff0000" + assert strokes[2] == "#00ff00" + # The middle one (None override) takes the theme default ("black") + assert strokes[1] == "black" + + +def test_channel_colors_wrong_length_raises(): + signals = np.random.randn(3, 100) + with pytest.raises(ValueError): + stackplot_svg.stackplot_svg( + signals, sample_frequency=200.0, channel_colors=["red", "blue"] + ) + + +# ---------- MontageDisplay resolution helpers ---------- + + +def test_resolve_ordered_labels_and_gaps_and_colors(): + d = MontageDisplay( + name="t", + groups=[ + ChannelGroup("a", ["A1", "A2"], color="red", gap_after_mm=4.0), + ChannelGroup("b", ["B1", "B2"], color="blue"), + ], + channel_overrides={"A2": ChannelStyle(label="A2", color="orange")}, + ) + assert d.resolve_ordered_labels() == ["A1", "A2", "B1", "B2"] + # A2 is last of group 'a' → gets group's gap_after_mm = 4.0 + assert d.resolve_gaps_mm() == [0.0, 4.0, 0.0, 0.0] + assert d.resolve_colors() == ["red", "orange", "blue", "blue"] + assert d.resolve_gains() == [1.0, 1.0, 1.0, 1.0] + + +def test_resolve_hidden_channel_is_dropped(): + d = MontageDisplay( + groups=[ + ChannelGroup("a", ["A1", "A2", "A3"], color="red", gap_after_mm=2.0), + ], + channel_overrides={"A2": ChannelStyle(label="A2", visible=False)}, + ) + assert d.resolve_ordered_labels() == ["A1", "A3"] + # A3 is now the last visible in the group, so it gets the group gap + assert d.resolve_gaps_mm() == [0.0, 2.0] + + +def test_channel_override_gap_wins_over_group_gap(): + d = MontageDisplay( + groups=[ + ChannelGroup("a", ["A1", "A2"], color="red", gap_after_mm=5.0), + ], + channel_overrides={"A2": ChannelStyle(label="A2", gap_after_mm=1.5)}, + ) + # A2 is last of group; override gap 1.5 wins over group gap 5.0 + assert d.resolve_gaps_mm() == [0.0, 1.5] + + +# ---------- JSON round-trip ---------- + + +def test_montage_display_json_roundtrip_with_ref(): + d = default_double_banana_display() + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "display.json" + d.save(path) + loaded = MontageDisplay.load(path) + assert loaded.name == d.name + assert loaded.derivation_ref == "double_banana" + assert [g.name for g in loaded.groups] == [g.name for g in d.groups] + assert loaded.groups[0].gap_after_mm == 3.0 + assert loaded.resolve_ordered_labels() == d.resolve_ordered_labels() + + +def test_montage_display_json_roundtrip_with_embedded_matrix(): + deriv = MontageDerivation( + montage_labels=["a-b", "b-c"], + rec_labels=["a", "b", "c"], + matrix=[[1.0, -1.0, 0.0], [0.0, 1.0, -1.0]], + reversed_polarity=False, + ) + d = MontageDisplay( + name="custom", + derivation=deriv, + groups=[ChannelGroup("only", ["a-b", "b-c"], color="green")], + ) + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "d.json" + d.save(path) + with open(path) as f: + raw = json.load(f) + assert raw["derivation"]["matrix"] == [[1.0, -1.0, 0.0], [0.0, 1.0, -1.0]] + loaded = MontageDisplay.load(path) + assert loaded.derivation is not None + assert loaded.derivation.matrix == [[1.0, -1.0, 0.0], [0.0, 1.0, -1.0]] + assert loaded.derivation.reversed_polarity is False + + +def test_json_tolerates_unknown_keys_with_validate_off(): + """Loading with validate=False ignores extra keys (forward-compat).""" + raw = { + "name": "x", + "groups": [ + { + "name": "g", + "channels": ["A"], + "color": "red", + "gap_after_mm": 0.0, + "extra_field_from_future_version": 42, + }, + ], + } + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "d.json" + with open(path, "w") as f: + json.dump(raw, f) + loaded = MontageDisplay.load(path, validate=False) + assert loaded.groups[0].name == "g" + + +def test_strict_validation_catches_unknown_keys(): + """With default validate=True, unknown keys raise.""" + import jsonschema + + raw = { + "name": "x", + "groups": [ + { + "name": "g", + "channels": ["A"], + "extra_field_from_future_version": 42, + }, + ], + } + with pytest.raises(jsonschema.ValidationError): + MontageDisplay.from_dict(raw) + + +# ---------- build_montage_view ---------- + + +def test_build_montage_view_from_ref_requires_rec_labels(): + d = MontageDisplay(derivation_ref="double_banana") + with pytest.raises(ValueError): + d.build_montage_view() + + +def test_build_montage_view_from_ref(): + rec = [ + "Fp1", + "Fp2", + "F7", + "F8", + "F3", + "F4", + "Fz", + "T3", + "T4", + "C3", + "C4", + "Cz", + "T5", + "T6", + "P3", + "P4", + "Pz", + "O1", + "O2", + ] + d = default_double_banana_display() + mv = d.build_montage_view(rec_labels=rec) + assert mv.shape == (18, 19) + assert "Fp1-F7" in list(mv.montage_labels) + + +def test_build_montage_view_from_embedded_matrix(): + deriv = MontageDerivation( + montage_labels=["a-b"], + rec_labels=["a", "b"], + matrix=[[1.0, -1.0]], + ) + d = MontageDisplay(derivation=deriv) + mv = d.build_montage_view() + assert mv.V.data.tolist() == [[1.0, -1.0]] + + +def test_build_montage_view_no_derivation_raises(): + d = MontageDisplay() + with pytest.raises(ValueError): + d.build_montage_view() + + +def test_derivation_ref_trace_is_identity_over_rec_labels(): + """'trace' is the pass-through derivation: V is the identity over + rec_labels, montage_labels == rec_labels. Reversed-polarity flips sign.""" + rec = ["Fp1", "Fp2", "F3", "C3"] + d = MontageDisplay( + name="t", + derivation_ref="trace", + groups=[ChannelGroup("g", rec)], + ) + mv = d.build_montage_view(rec_labels=rec) + expected = -np.eye(len(rec)) # default reversed_polarity=True + assert np.allclose(mv.V.data, expected) + assert list(mv.montage_labels) == rec + + +def test_derivation_ref_trace_lists_in_builtins(): + """Regression: 'trace' must be in the registry so derivation_ref works.""" + assert "trace" in list_builtin_derivations() + + +def test_list_builtin_derivations_contains_known(): + names = list_builtin_derivations() + assert "double_banana" in names + assert "tcp" in names + assert "laplacian" in names + + +# ---------- show_montage_display_svg end-to-end ---------- + + +def _synth_rec(num_samples=400, fs=200.0): + rec_labels = [ + "Fp1", + "Fp2", + "F7", + "F8", + "F3", + "F4", + "Fz", + "T3", + "T4", + "C3", + "C4", + "Cz", + "T5", + "T6", + "P3", + "P4", + "Pz", + "O1", + "O2", + ] + rng = np.random.RandomState(7) + signals = rng.randn(len(rec_labels), num_samples) * 20.0 + return signals, fs, rec_labels + + +def test_show_montage_display_svg_renders_grouped_double_banana(): + signals, fs, rec = _synth_rec() + d = default_double_banana_display() + svg_str = show_montage_display_svg(signals, fs, d, rec_labels=rec, sensitivity=7.0) + root = ET.fromstring(svg_str) + channels = root.findall(".//svg:g[@class='channel']", NS) + assert len(channels) == 18 + + labels = [ch.attrib["data-channel-name"] for ch in channels] + # Display order present (channels are in display order in the SVG, though + # topdown may have reversed visual order; data-channel-name still matches). + assert "Fp1-F7" in labels + assert "Cz-Pz" in labels + + +def test_show_montage_display_svg_applies_group_colors(): + signals, fs, rec = _synth_rec() + d = default_double_banana_display() + svg_str = show_montage_display_svg(signals, fs, d, rec_labels=rec, sensitivity=7.0) + root = ET.fromstring(svg_str) + polys = root.findall(".//svg:polyline[@class='trace']", NS) + strokes = {p.attrib["stroke"] for p in polys} + assert "#1f4e79" in strokes # left chains + assert "#a8323e" in strokes # right chains + assert "#000000" in strokes # midline + + +def test_save_montage_display_svg_writes_file(): + signals, fs, rec = _synth_rec() + d = default_double_banana_display() + with tempfile.TemporaryDirectory() as td: + out = Path(td) / "out.svg" + save_montage_display_svg(out, signals, fs, d, rec_labels=rec, sensitivity=7.0) + assert out.exists() and out.stat().st_size > 0 + text = out.read_text(encoding="utf-8") + assert " Date: Thu, 14 May 2026 15:44:31 -0700 Subject: [PATCH 16/17] montage editor and schema expansion --- eegvis/stackplot_svg.py | 127 ++++++++++++++- tests/test_montage_display.py | 296 ++++++++++++++++++++++++++++++++++ 2 files changed, 421 insertions(+), 2 deletions(-) diff --git a/eegvis/stackplot_svg.py b/eegvis/stackplot_svg.py index 1b99fa2..e9bbc48 100644 --- a/eegvis/stackplot_svg.py +++ b/eegvis/stackplot_svg.py @@ -11,6 +11,8 @@ """ from dataclasses import dataclass, field +from typing import List, Optional + import numpy as np import xml.etree.ElementTree as ET @@ -187,6 +189,40 @@ def bandpass_filter(signals, sample_frequency, low_freq=1.0, high_freq=70.0): return result +def apply_per_channel_bandpass( + signals, sample_frequency, channel_lf=None, channel_hf=None +): + """Apply bandpass filtering with per-channel cutoffs. + + ``channel_lf`` and ``channel_hf`` are sequences of length + ``num_channels``. An entry of ``None``, ``0``, or a negative value + skips that direction for that channel. Channels with both set are + bandpassed; channels with neither are passed through untouched. + + Internally just calls :func:`bandpass_filter` on each row that needs + filtering — slow for very many channels with disparate cutoffs, but + fine for clinical 20-channel work. + """ + if channel_lf is None and channel_hf is None: + return signals + num_channels = signals.shape[0] + out = signals.copy() + for ch in range(num_channels): + lf = channel_lf[ch] if channel_lf is not None else None + hf = channel_hf[ch] if channel_hf is not None else None + # Normalize "no filter" semantics: 0 or negative or None = skip. + if lf is not None and lf <= 0: + lf = None + if hf is not None and hf <= 0: + hf = None + if lf is None and hf is None: + continue + row = signals[ch : ch + 1] + filtered = bandpass_filter(row, sample_frequency, low_freq=lf, high_freq=hf) + out[ch] = filtered[0] + return out + + def notch_filter(signals, sample_frequency, notch_freq=60.0, Q=30.0): """Apply a zero-phase notch (band-stop) filter to remove line noise. @@ -313,6 +349,8 @@ def stackplot_svg( color_group_size=4, channel_gaps_mm=None, channel_colors=None, + channel_widths=None, + channel_cal=None, preserve_aspect_ratio=None, ): """Generate an SVG string of stacked EEG traces. @@ -358,6 +396,14 @@ def stackplot_svg( (length must equal num_channels). An entry of None falls back to the theme's group-cycled color. Channels are matched by their original index in the signals array, not by display order. + channel_widths: optional per-channel sequence of trace stroke widths + in mm (length must equal num_channels). An entry of None falls + back to the theme's ``trace_width`` (or ``linewidth`` if set). + channel_cal: optional per-channel sequence of calibration amplitudes + in µV (length must equal num_channels). When set, a small + "µV" annotation is rendered at the right edge of each + channel's trace. An entry of None disables the annotation for + that channel. preserve_aspect_ratio: optional value for the SVG root's ``preserveAspectRatio`` attribute. Leave as None (default) to omit the attribute and rely on the SVG default of @@ -429,6 +475,19 @@ def stackplot_svg( f"got {len(channel_colors)}" ) + if channel_widths is not None: + if len(channel_widths) != num_channels: + raise ValueError( + f"channel_widths must have length {num_channels}, " + f"got {len(channel_widths)}" + ) + + if channel_cal is not None: + if len(channel_cal) != num_channels: + raise ValueError( + f"channel_cal must have length {num_channels}, got {len(channel_cal)}" + ) + # compute vertical offsets in data space ticklocs, dr = _compute_channel_offsets( yscale_ref * data, @@ -619,6 +678,11 @@ def data_y_to_svg(y_data): }, ).text = label_order[draw_idx] + # per-channel stroke width override (matched by original ch_idx) + ch_width = trace_width + if channel_widths is not None and channel_widths[ch_idx] is not None: + ch_width = float(channel_widths[ch_idx]) + # polyline y_raw = data[:, ch_idx] points_str = _format_points(t_svg, y_raw) @@ -629,12 +693,27 @@ def data_y_to_svg(y_data): "points": points_str, "class": "trace", "stroke": ch_color, - "stroke-width": str(trace_width), + "stroke-width": str(ch_width), "transform": f"scale(1,{y_scale_factor:.6f})", "data-yscale": f"{y_scale_factor:.6f}", }, ) + # per-channel calibration annotation (small text at right edge) + if channel_cal is not None and channel_cal[ch_idx] is not None: + cal_val = float(channel_cal[ch_idx]) + ET.SubElement( + ch_g, + "text", + { + "x": f"{label_margin + plot_width + 1:.2f}", + "y": "0", + "class": "channel-cal", + "font-size": "2.2", + "fill": "#666", + }, + ).text = f"{cal_val:g}µV" + # annotations layer ET.SubElement(svg, "g", {"class": "annotations"}) @@ -808,6 +887,19 @@ def show_montage_display_svg( gaps_mm = display.resolve_gaps_mm() colors = display.resolve_colors() gains = display.resolve_gains() + # Per-channel clinical attributes — None means "inherit global". + per_chan_sens: List[Optional[float]] = [] + per_chan_lf: List[Optional[float]] = [] + per_chan_hf: List[Optional[float]] = [] + per_chan_cal: List[Optional[float]] = [] + per_chan_width: List[Optional[float]] = [] + for lbl in ordered: + override = display.channel_overrides.get(lbl) + per_chan_sens.append(override.sensitivity if override else None) + per_chan_lf.append(override.lf if override else None) + per_chan_hf.append(override.hf if override else None) + per_chan_cal.append(override.cal if override else None) + per_chan_width.append(override.width if override else None) label_to_row = {lbl: i for i, lbl in enumerate(montage_labels)} try: @@ -819,6 +911,18 @@ def show_montage_display_svg( ) from None derived_ordered = derived[indices] + # Apply per-channel bandpass before stacking (only if any channel has + # LF or HF set). + if any(v is not None for v in per_chan_lf) or any( + v is not None for v in per_chan_hf + ): + derived_ordered = apply_per_channel_bandpass( + derived_ordered, + sample_frequency, + channel_lf=per_chan_lf, + channel_hf=per_chan_hf, + ) + # MontageDisplay lists channels top-down (file order matches visual # order); stackplot_svg numbers channels bottom-up. Reverse so the # first-listed channel ends up at the top of the page. The gap below @@ -831,6 +935,9 @@ def show_montage_display_svg( ordered_render = list(reversed(ordered)) colors_render = list(reversed(colors)) gains_render = list(reversed(gains)) + widths_render = list(reversed(per_chan_width)) + cal_render = list(reversed(per_chan_cal)) + sens_render = list(reversed(per_chan_sens)) if n > 0: gaps_render = list(reversed(gaps_mm[:-1])) + [0.0] else: @@ -844,7 +951,21 @@ def show_montage_display_svg( gains_render, dtype=float ) - for reserved in ("ylabels", "channel_gaps_mm", "channel_colors"): + # Fold per-channel sensitivity into yscale: smaller channel sens means + # larger trace, so yscale[i] *= global_sens / channel_sens[i]. + global_sens = kwargs.get("sensitivity") + if global_sens is not None: + for i, ch_sens in enumerate(sens_render): + if ch_sens is not None and ch_sens > 0: + yscale_arr[i] *= float(global_sens) / float(ch_sens) + + for reserved in ( + "ylabels", + "channel_gaps_mm", + "channel_colors", + "channel_widths", + "channel_cal", + ): kwargs.pop(reserved, None) return stackplot_svg( @@ -854,6 +975,8 @@ def show_montage_display_svg( yscale=yscale_arr, channel_gaps_mm=gaps_render, channel_colors=colors_render, + channel_widths=widths_render, + channel_cal=cal_render, **kwargs, ) diff --git a/tests/test_montage_display.py b/tests/test_montage_display.py index 231b968..29230bd 100644 --- a/tests/test_montage_display.py +++ b/tests/test_montage_display.py @@ -832,3 +832,299 @@ def test_error_message_for_negative_gap(): ) assert "groups[0].gap_after_mm" in msg assert "minimum" in msg + + +# ---------- Per-channel clinical attributes (Sens/LF/HF/CAL/Width) ---------- + + +def test_channel_style_accepts_new_attributes(): + style = ChannelStyle( + label="Fp1-F7", + sensitivity=7.0, + lf=1.0, + hf=70.0, + cal=50.0, + width=0.4, + ) + assert style.sensitivity == 7.0 + assert style.lf == 1.0 + assert style.hf == 70.0 + assert style.cal == 50.0 + assert style.width == 0.4 + + +def test_schema_accepts_per_channel_attributes(): + from eegvis.montage_display import validate_montage_display_dict + + raw = { + "name": "x", + "derivation": { + "type": "symbolic", + "channels": [{"label": "A-B", "diffpair": ["A", "B"]}], + }, + "groups": [{"name": "g", "channels": ["A-B"]}], + "channel_overrides": { + "A-B": { + "label": "A-B", + "sensitivity": 7.0, + "lf": 1.0, + "hf": 70.0, + "cal": 50.0, + "width": 0.4, + }, + }, + } + validate_montage_display_dict(raw) # no raise + + +def test_schema_rejects_zero_or_negative_sensitivity(): + import jsonschema + from eegvis.montage_display import validate_montage_display_dict + + raw = { + "name": "x", + "derivation": { + "type": "symbolic", + "channels": [{"label": "A-B", "diffpair": ["A", "B"]}], + }, + "groups": [{"name": "g", "channels": ["A-B"]}], + "channel_overrides": { + "A-B": {"label": "A-B", "sensitivity": 0}, + }, + } + with pytest.raises(jsonschema.ValidationError): + validate_montage_display_dict(raw) + + +def test_schema_allows_null_to_clear_per_channel_attr(): + from eegvis.montage_display import validate_montage_display_dict + + raw = { + "name": "x", + "derivation": { + "type": "symbolic", + "channels": [{"label": "A-B", "diffpair": ["A", "B"]}], + }, + "groups": [{"name": "g", "channels": ["A-B"]}], + "channel_overrides": { + "A-B": {"label": "A-B", "sensitivity": None, "lf": None}, + }, + } + validate_montage_display_dict(raw) # null is allowed + + +def test_json_roundtrip_preserves_per_channel_attributes(): + """JSON save/load preserves the new fields.""" + d = MontageDisplay( + name="rt", + derivation=SymbolicDerivation( + channels=[SymbolicChannel(label="A-B", diffpair=["A", "B"])] + ), + groups=[ChannelGroup("g", ["A-B"])], + channel_overrides={ + "A-B": ChannelStyle( + label="A-B", sensitivity=7.0, lf=1.0, hf=70.0, cal=50.0, width=0.4 + ) + }, + ) + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "rt.json" + d.save(path) + loaded = MontageDisplay.load(path) + style = loaded.channel_overrides["A-B"] + assert style.sensitivity == 7.0 + assert style.lf == 1.0 + assert style.hf == 70.0 + assert style.cal == 50.0 + assert style.width == 0.4 + + +def test_editor_session_roundtrips_per_channel_attributes(): + """EditorRow attrs survive to_display() -> from_display().""" + from eegvis.viewer.editor_session import EditorRow, EditorSession + + sess = EditorSession( + name="rt", + rows=[ + EditorRow( + kind="channel", + g1="A", + g2="B", + color="#1f4e79", + sensitivity=7.0, + lf=1.0, + hf=70.0, + cal=50.0, + width=0.4, + ) + ], + ) + display = sess.to_display() + style = display.channel_overrides["A-B"] + assert style.sensitivity == 7.0 + assert style.width == 0.4 + + restored = EditorSession.from_display(display) + assert len(restored.rows) == 1 + row = restored.rows[0] + assert row.sensitivity == 7.0 + assert row.lf == 1.0 + assert row.hf == 70.0 + assert row.cal == 50.0 + assert row.width == 0.4 + + +def test_editor_set_cell_parses_numeric_fields(): + from eegvis.viewer.editor_session import EditorRow, EditorSession + + sess = EditorSession(rows=[EditorRow(kind="channel", g1="A", g2="B")]) + sess.set_cell(0, "sensitivity", "7.5") + sess.set_cell(0, "lf", "1.0") + sess.set_cell(0, "width", "") # empty clears + assert sess.rows[0].sensitivity == 7.5 + assert sess.rows[0].lf == 1.0 + assert sess.rows[0].width is None + + +# ---------- Renderer wire-through for per-channel attrs ---------- + + +def _synth_for_overrides(): + """Tiny 3-channel synthetic for the override-rendering tests.""" + fs = 200.0 + rec = ["A", "B", "C"] + t = np.arange(int(fs * 2)) / fs + rng = np.random.default_rng(0) + signals = np.zeros((3, len(t))) + for i in range(3): + signals[i] = rng.normal(0, 5, len(t)) + 10 * np.sin(2 * np.pi * 10 * t) + return signals, fs, rec + + +def _build_display_with_overrides(overrides): + return MontageDisplay( + name="t", + derivation=SymbolicDerivation( + channels=[ + SymbolicChannel(label="A-B", diffpair=["A", "B"]), + SymbolicChannel(label="B-C", diffpair=["B", "C"]), + ], + ), + groups=[ChannelGroup("g", ["A-B", "B-C"])], + channel_overrides=overrides, + ) + + +def test_per_channel_width_lands_on_polyline_stroke_width(): + signals, fs, rec = _synth_for_overrides() + d = _build_display_with_overrides({"A-B": ChannelStyle(label="A-B", width=0.9)}) + svg_str = show_montage_display_svg(signals, fs, d, rec_labels=rec, sensitivity=7.0) + root = ET.fromstring(svg_str) + # Match by data-channel-name (independent of bottom-up render order) + polys = {} + for ch in root.findall(".//svg:g[@class='channel']", NS): + name = ch.attrib["data-channel-name"] + poly = ch.find("./svg:polyline", NS) + polys[name] = float(poly.attrib["stroke-width"]) + assert polys["A-B"] == 0.9 + # B-C falls back to theme default + assert polys["B-C"] != 0.9 + + +def test_per_channel_cal_renders_annotation(): + signals, fs, rec = _synth_for_overrides() + d = _build_display_with_overrides({"A-B": ChannelStyle(label="A-B", cal=50.0)}) + svg_str = show_montage_display_svg(signals, fs, d, rec_labels=rec, sensitivity=7.0) + root = ET.fromstring(svg_str) + cal_texts = [] + for ch in root.findall(".//svg:g[@class='channel']", NS): + for txt in ch.findall("./svg:text[@class='channel-cal']", NS): + cal_texts.append((ch.attrib["data-channel-name"], txt.text)) + assert ("A-B", "50µV") in cal_texts + # B-C has no override → no annotation + assert not any(name == "B-C" for name, _ in cal_texts) + + +def test_per_channel_sensitivity_scales_yscale(): + """Channel with smaller per-channel sensitivity should render with a + larger y_scale_factor (more sensitive = bigger amplitude).""" + signals, fs, rec = _synth_for_overrides() + # Channel A-B at half the global sensitivity should be 2x larger. + d = _build_display_with_overrides( + {"A-B": ChannelStyle(label="A-B", sensitivity=3.5)} # global is 7.0 + ) + svg_str = show_montage_display_svg(signals, fs, d, rec_labels=rec, sensitivity=7.0) + root = ET.fromstring(svg_str) + yscales = {} + for ch in root.findall(".//svg:g[@class='channel']", NS): + name = ch.attrib["data-channel-name"] + poly = ch.find("./svg:polyline", NS) + yscales[name] = float(poly.attrib["data-yscale"]) + # A-B should be roughly 2x B-C + assert yscales["A-B"] / yscales["B-C"] == pytest.approx(2.0, rel=1e-3) + + +def test_per_channel_lf_hf_changes_trace_points(): + """When LF/HF are set per-channel, the corresponding polyline points + should differ from the unfiltered case for that channel only.""" + signals, fs, rec = _synth_for_overrides() + d_plain = _build_display_with_overrides({}) + d_filt = _build_display_with_overrides( + {"A-B": ChannelStyle(label="A-B", lf=2.0, hf=20.0)} + ) + plain = ET.fromstring( + show_montage_display_svg(signals, fs, d_plain, rec_labels=rec, sensitivity=7.0) + ) + filt = ET.fromstring( + show_montage_display_svg(signals, fs, d_filt, rec_labels=rec, sensitivity=7.0) + ) + + def points(root, name): + for ch in root.findall(".//svg:g[@class='channel']", NS): + if ch.attrib["data-channel-name"] == name: + return ch.find("./svg:polyline", NS).attrib["points"] + return None + + assert points(plain, "A-B") != points(filt, "A-B"), ( + "A-B trace should change after per-channel filtering" + ) + # B-C had no override — its trace should be identical. + assert points(plain, "B-C") == points(filt, "B-C") + + +def test_channel_widths_length_validation(): + """stackplot_svg should raise if channel_widths length != num_channels.""" + signals = np.zeros((3, 100)) + with pytest.raises(ValueError, match="channel_widths must have length 3"): + stackplot_svg.stackplot_svg( + signals, sample_frequency=100.0, channel_widths=[0.5, 0.5] + ) + + +def test_channel_cal_length_validation(): + signals = np.zeros((3, 100)) + with pytest.raises(ValueError, match="channel_cal must have length 3"): + stackplot_svg.stackplot_svg( + signals, sample_frequency=100.0, channel_cal=[50, 50] + ) + + +def test_apply_per_channel_bandpass_only_filters_specified_rows(): + from eegvis.stackplot_svg import apply_per_channel_bandpass + + fs = 200.0 + t = np.arange(int(fs * 2)) / fs + signals = np.stack( + [ + np.sin(2 * np.pi * 10 * t), + np.sin(2 * np.pi * 10 * t), + np.sin(2 * np.pi * 10 * t), + ] + ) + out = apply_per_channel_bandpass( + signals, fs, channel_lf=[None, 0.5, None], channel_hf=[None, 30.0, None] + ) + # Row 0 and 2 should be byte-identical, row 1 should differ. + assert np.array_equal(out[0], signals[0]) + assert np.array_equal(out[2], signals[2]) + assert not np.array_equal(out[1], signals[1]) From ef1b1c20abfd1e6212f6693ccfcf3d6b9bca23f5 Mon Sep 17 00:00:00 2001 From: Christopher Lee-Messer Date: Fri, 15 May 2026 15:58:33 -0700 Subject: [PATCH 17/17] montage files and editor --- docs/gallery/build.py | 246 +++++++ eegvis/displays/SCHEMA.md | 395 +++++++++++ eegvis/displays/__init__.py | 111 +++ eegvis/displays/circle.json | 122 ++++ eegvis/displays/double_banana.json | 193 ++++++ eegvis/displays/double_banana_avg.json | 227 ++++++ eegvis/displays/double_banana_paired.json | 193 ++++++ eegvis/displays/montage_display.schema.json | 213 ++++++ eegvis/displays/neonatal.json | 191 ++++++ eegvis/displays/tcp.json | 209 ++++++ eegvis/displays/true_sphenoidal.json | 226 ++++++ eegvis/montage_display.py | 719 ++++++++++++++++++++ eegvis/viewer/editor_components.py | 376 ++++++++++ eegvis/viewer/editor_session.py | 272 ++++++++ 14 files changed, 3693 insertions(+) create mode 100644 docs/gallery/build.py create mode 100644 eegvis/displays/SCHEMA.md create mode 100644 eegvis/displays/__init__.py create mode 100644 eegvis/displays/circle.json create mode 100644 eegvis/displays/double_banana.json create mode 100644 eegvis/displays/double_banana_avg.json create mode 100644 eegvis/displays/double_banana_paired.json create mode 100644 eegvis/displays/montage_display.schema.json create mode 100644 eegvis/displays/neonatal.json create mode 100644 eegvis/displays/tcp.json create mode 100644 eegvis/displays/true_sphenoidal.json create mode 100644 eegvis/montage_display.py create mode 100644 eegvis/viewer/editor_components.py create mode 100644 eegvis/viewer/editor_session.py diff --git a/docs/gallery/build.py b/docs/gallery/build.py new file mode 100644 index 0000000..81b7e9f --- /dev/null +++ b/docs/gallery/build.py @@ -0,0 +1,246 @@ +"""Generate a montage × theme gallery into ``docs/gallery/``. + +Renders every bundled :class:`MontageDisplay` profile against every +bundled :class:`Theme` using a single synthetic 23-channel demo dataset +(standard 10-20 plus A1/A2 and Sp1/Sp2 for TCP / true sphenoidal). Writes +one SVG per combination and a static ``index.html`` that lays them out as +a grid for at-a-glance comparison. + +Run with: + + uv run python -m docs.gallery.build + # or + uv run python docs/gallery/build.py +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Dict, List, Tuple + +import numpy as np + +from eegvis.displays import list_displays, load_display +from eegvis.stackplot_svg import ( + DEFAULT_THEME, + PUBLICATION_THEME, + STRATUS_THEME, + save_montage_display_svg, +) + + +GALLERY_DIR = Path(__file__).resolve().parent + +# --------------------------------------------------------------------------- +# Synthetic recording — large enough to cover every bundled montage. +# Mixed-case electrodes match the schema-side convention (matching the +# normalized neonatal labels post-Fp1 fix). +# --------------------------------------------------------------------------- + +REC_LABELS = [ + # standard 10-20 + "Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4", "O1", "O2", + "F7", "F8", "T3", "T4", "T5", "T6", "Fz", "Cz", "Pz", + # ear references for TCP + "A1", "A2", + # sphenoidal needles for true_sphenoidal + "Sp1", "Sp2", +] + +# What electrodes does each bundled montage rely on? Used only to gate +# which combinations actually get rendered (we skip a montage if our +# synthetic recording is missing required leads — never happens with the +# 23-channel set above, but the structure keeps this future-proof). +REQUIRED: Dict[str, List[str]] = { + "double_banana": ["Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4", "O1", "O2", + "F7", "F8", "T3", "T4", "T5", "T6", "Fz", "Cz", "Pz"], + "double_banana_paired": ["Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4", + "O1", "O2", "F7", "F8", "T3", "T4", "T5", "T6", + "Fz", "Cz", "Pz"], + "double_banana_avg": ["Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4", + "O1", "O2", "F7", "F8", "T3", "T4", "T5", "T6", + "Fz", "Cz", "Pz"], + "circle": ["Fp1", "Fp2", "F7", "T3", "T5", "O1", "O2", "T6", "T4", "F8"], + "neonatal": ["Fp1", "Fp2", "C3", "C4", "T3", "T4", "O1", "O2", "Cz", "Fz", "Pz"], + "tcp": ["Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4", "O1", "O2", + "F7", "F8", "T3", "T4", "T5", "T6", "Cz", "A1", "A2"], + "true_sphenoidal": ["Fp1", "Fp2", "F3", "F4", "C3", "C4", "P3", "P4", + "O1", "O2", "F7", "F8", "T3", "T4", "T5", "T6", + "Fz", "Cz", "Pz", "Sp1", "Sp2"], +} + +THEMES = [ + ("default", DEFAULT_THEME), + ("publication", PUBLICATION_THEME), + ("stratus", STRATUS_THEME), +] + + +def synth_recording( + rec_labels: List[str], fs: float = 256.0, seconds: float = 10.0 +) -> np.ndarray: + """Generate ``(num_channels, num_samples)`` of pseudo-EEG. + + Each channel is a mix of alpha (10 Hz), theta (6 Hz), random noise, + and a 60 Hz line component, with channel-specific phases — same recipe + as ``create_demo_study()`` in the viewer. + """ + rng = np.random.default_rng(42) + n_samples = int(fs * seconds) + t = np.arange(n_samples) / fs + n = len(rec_labels) + sig = np.zeros((n, n_samples)) + for i in range(n): + alpha = rng.uniform(10, 40) * np.sin( + 2 * np.pi * (10 + rng.uniform(-1, 1)) * t + rng.uniform(0, 2 * np.pi) + ) + theta = rng.uniform(5, 15) * np.sin( + 2 * np.pi * (6 + rng.uniform(-0.5, 0.5)) * t + rng.uniform(0, 2 * np.pi) + ) + sig[i] = alpha + theta + rng.normal(0, 5, n_samples) + 3 * np.sin( + 2 * np.pi * 60 * t + ) + return sig + + +def render_one(name: str, theme_name: str, theme, signals, fs) -> Path: + """Render one ``(montage, theme)`` combination to an SVG file.""" + out = GALLERY_DIR / f"{name}__{theme_name}.svg" + display = load_display(name) + save_montage_display_svg( + out, + signals, + fs, + display, + rec_labels=REC_LABELS, + seconds=10.0, + sensitivity=10.0, + width_mm=300, + height_mm=180, + theme=theme, + max_samples_per_channel=2000, + ) + return out + + +def write_index_html(entries: List[Tuple[str, str, Path]]) -> Path: + """Compose an HTML page that arranges the SVGs as a grid.""" + montages = sorted({m for m, _, _ in entries}) + themes = [t for t, _ in THEMES] + + # build a lookup: (montage, theme) -> relative path + lookup: Dict[Tuple[str, str], Path] = {(m, t): p for m, t, p in entries} + + style = """ + :root { + --grid-gap: 12px; + --pad: 16px; + --border: #d0d0d0; + --label: #444; + } + * { box-sizing: border-box; } + body { + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + margin: 0; padding: var(--pad); + background: #fafafa; color: #222; + } + h1 { font-size: 20px; margin: 0 0 4px; } + p.lede { color: #666; margin: 0 0 16px; max-width: 60em; } + .grid { + display: grid; + grid-template-columns: 8em repeat(var(--cols), minmax(0, 1fr)); + gap: var(--grid-gap); + align-items: start; + } + .col-header, .row-header { + font-size: 12px; color: var(--label); text-transform: uppercase; + letter-spacing: 0.06em; font-weight: 600; + } + .col-header { text-align: center; padding: 4px 0; } + .row-header { + align-self: center; text-align: right; padding-right: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; text-transform: none; letter-spacing: 0; + } + .cell { + background: white; border: 1px solid var(--border); border-radius: 4px; + padding: 4px; overflow: hidden; + } + .cell object { width: 100%; height: auto; display: block; } + .empty { color: #aaa; font-style: italic; padding: 12px; text-align: center; } + footer { + margin-top: 24px; color: #888; font-size: 12px; + } + """ + + rows = [ + '', + '', + "eegvis montage × theme gallery", + f"", + "", + "

eegvis montage × theme gallery

", + '

Every bundled MontageDisplay profile (rows) rendered ' + "with every bundled Theme (columns) against a synthetic 23-channel " + "demo recording. Generated by docs/gallery/build.py.

", + f'
', + # header row + '
', + ] + for theme_name in themes: + rows.append(f'
{theme_name}
') + for montage in montages: + rows.append(f'
{montage}
') + for theme_name in themes: + path = lookup.get((montage, theme_name)) + if path is None: + rows.append('
') + else: + rel = path.name + rows.append( + f'
' + f'' + f"
" + ) + rows.append("
") + rows.append( + '
Source: docs/gallery/build.py · ' + "Generated from the bundled profiles in " + "eegvis/displays/ and the themes in " + "eegvis/stackplot_svg.py.
" + ) + rows.append("") + + out = GALLERY_DIR / "index.html" + out.write_text("\n".join(rows), encoding="utf-8") + return out + + +def main() -> None: + GALLERY_DIR.mkdir(parents=True, exist_ok=True) + fs = 256.0 + signals = synth_recording(REC_LABELS, fs=fs, seconds=10.0) + + entries: List[Tuple[str, str, Path]] = [] + for montage in list_displays(): + # If the montage needs leads we don't have, skip it for now. + needed = REQUIRED.get(montage, []) + missing = [e for e in needed if e not in REC_LABELS] + if missing: + print(f" skip {montage}: missing {missing}") + continue + for theme_name, theme in THEMES: + try: + path = render_one(montage, theme_name, theme, signals, fs) + entries.append((montage, theme_name, path)) + print(f" wrote {path.relative_to(GALLERY_DIR.parent.parent)}") + except Exception as e: + print(f" FAIL {montage} × {theme_name}: {e}") + + index = write_index_html(entries) + print(f"\nindex: {index.relative_to(GALLERY_DIR.parent.parent)}") + print(f"total: {len(entries)} SVGs") + + +if __name__ == "__main__": + main() diff --git a/eegvis/displays/SCHEMA.md b/eegvis/displays/SCHEMA.md new file mode 100644 index 0000000..1a70a74 --- /dev/null +++ b/eegvis/displays/SCHEMA.md @@ -0,0 +1,395 @@ +# MontageDisplay JSON schema + +This document specifies the file format for the JSON profiles in this +directory and the semantics that load/render code is expected to honor. +It is the canonical reference. Where the spec differs from current +behavior the deltas are flagged with **Δ today** notes. + +A profile bundles three things into one file: + +1. **A derivation** — the linear combination math that turns recording-electrode + signals (channels × samples) into derived channels (e.g. `Fp1-F7 = Fp1 - F7`, + or `Fp1-AVG = Fp1 - mean(...)`). +2. **A display ordering** — groups of derived channels with default colors and + per-group spacers. +3. **Per-channel overrides** — color, gain, gap, visibility, by label. + +The renderer (`stackplot_svg.show_montage_display_svg`) reads the profile, +applies the derivation matrix to the input signals, and lays the result out +according to the groups + overrides. + +## Top-level structure + +```json +{ + "name": "double_banana", + "description": "Standard 18-channel double-banana ...", + "derivation_ref": null, + "derivation": { ... }, + "groups": [ ... ], + "channel_overrides": { ... } +} +``` + +Fields: + +| field | type | required | meaning | +|---------------------|----------------|----------|-----------------------------------------------------------------------------------------| +| `name` | string | yes | Short identifier. Convention: lower_snake_case, matches the filename stem. | +| `description` | string | optional | Free-form clinical / authoring notes. | +| `derivation_ref` | string \| null | one-of | Name of a built-in `MontageView` factory (e.g. `"double_banana"`, `"true_sphenoidal"`). | +| `derivation` | object \| null | one-of | Self-contained derivation block (see below). | +| `groups` | array | yes | Ordered list of `ChannelGroup` objects. | +| `channel_overrides` | object | optional | Map of `` → `ChannelStyle`. | + +Exactly one of `derivation_ref` and `derivation` must be set. `derivation_ref` +defers the math to a Python class registered with +`register_builtin_derivation`; `derivation` carries the math inline so the +file is portable. + +### Order convention + +Channels listed earlier in `groups` (and earlier within a group's `channels` +array) are drawn **higher on the page**. `gap_after_mm` on a group or +channel inserts space *below* it. + +## Derivation block + +The `derivation` object has a `type` discriminator with two values: + +### `type: "symbolic"` (preferred for new profiles) + +Defines the derivation as a list of named channels, each a symbolic linear +combination of recording electrodes and optional named *virtual* electrodes. +The matrix is built at apply time once `rec_labels` are known, so the same +JSON works across recordings with different electrode sets (as long as the +named electrodes are present). + +```json +"derivation": { + "type": "symbolic", + "reversed_polarity": true, + "virtual_channels": { + "AVG": { + "type": "mean", + "electrodes": ["Fp1", "F7", "T3", "T5", "Fp2", "F8", "T4", "T6", + "F3", "C3", "P3", "O1", "F4", "C4", "P4", "O2", + "Fz", "Cz", "Pz"] + } + }, + "channels": [ + {"label": "Fp1-F7", "diffpair": ["Fp1", "F7"]}, + {"label": "Fp1-AVG", "diffpair": ["Fp1", "AVG"]}, + {"label": "weighted", + "sum_coefficients": {"Fp1": 0.5, "F7": -0.5, "T3": 0.5, "T5": -0.5}} + ] +} +``` + +Fields: + +| field | type | required | meaning | +|---|---|---|---| +| `type` | string | yes | Must be `"symbolic"`. | +| `reversed_polarity` | bool | optional (default `true`) | If `true`, the final matrix is multiplied by −1 so positive deflections render downward (clinical "negative up" convention). | +| `virtual_channels` | object | optional | Map of `` → `VirtualElectrode`. | +| `channels` | array | yes | Ordered list of `SymbolicChannel`. Each row in the order it appears becomes one derived channel; the order of `groups[].channels` references these labels but does not have to match this order. | + +#### VirtualElectrode + +A named linear combination of physical electrodes. Used wherever an +electrode name is referenced (in `diffpair` or `sum_coefficients`). Virtual +names take precedence over physical electrodes with the same name — pick +names that don't collide with your recording channels. + +Two flavors: + +**`type: "mean"`** — equal-weight average of the listed electrodes. + +```json +{"type": "mean", "electrodes": ["A1", "A2"]} +``` + +Resolves to coefficient `1/N` on each listed electrode, where `N` is the +length of `electrodes`. Useful for common-average reference, linked-ears +average (`mean(A1, A2)`), etc. + +**`type: "weighted"`** — explicit weights. + +```json +{"type": "weighted", "weights": {"A1": 0.5, "A2": 0.5}} +``` + +Resolves to the literal coefficient map (no normalization). + +#### SymbolicChannel + +One derived channel. Exactly one of `diffpair` and `sum_coefficients` must be +set. + +| field | type | meaning | +|---|---|---| +| `label` | string | Channel label. Used by `groups[].channels` and `channel_overrides`. | +| `diffpair` | `[string, string]` | Bipolar shorthand: `[a, b]` → coefficient +1 on `a`, −1 on `b`. Either name may reference a virtual channel. | +| `sum_coefficients` | object | General linear combination: map of electrode (or virtual) name → coefficient. Names not listed get coefficient 0. | + +**Reference resolution**: when a name appears in `diffpair` or +`sum_coefficients`, it is looked up first in `virtual_channels`, then in +`rec_labels`. Unresolved names raise an error at build time so authors +catch typos early. + +**Polarity reversal**: `reversed_polarity: true` flips the sign of the +entire matrix after virtuals have been resolved. It does *not* apply to +individual rows. To make a specific channel polarity-positive while the +rest are reversed, invert the entries inside that row's +`sum_coefficients`. + +### `type: "matrix"` (legacy / interop) + +Carries the resolved matrix explicitly. Locked to a specific `rec_labels` +ordering so it is not portable across recordings with different electrode +layouts. Useful for fully self-contained exports. + +```json +"derivation": { + "type": "matrix", + "reversed_polarity": true, + "montage_labels": ["Fp1-F7", "F7-T3", ...], + "rec_labels": ["Fp1", "Fp2", "F3", ...], + "matrix": [[1, 0, 0, ...], [0, 0, ...], ...] +} +``` + +`matrix.shape` must be `(len(montage_labels), len(rec_labels))`. + +> **Δ today**: the current `MontageDerivation` dataclass omits the `type` +> field. The new spec adds it as a discriminator for forward-compatibility +> with the symbolic form. A loader that sees a `derivation` block without +> a `type` field treats it as `"matrix"`. + +## Groups (`groups[]`) + +```json +{ + "name": "left_temporal", + "channels": ["Fp1-F7", "F7-T3", "T3-T5", "T5-O1"], + "color": "#1f4e79", + "gap_after_mm": 5.0 +} +``` + +| field | type | required | meaning | +|---|---|---|---| +| `name` | string | yes | Identifier, not displayed. | +| `channels` | array of strings | yes | Channel labels in render order (top to bottom within the group). Each must match a `derivation.channels[].label` (or `derivation_ref`'s output). Hidden channels (via `channel_overrides`) are skipped silently. | +| `color` | string \| null | optional | CSS color applied to the group's traces unless an override wins. `null` falls back to the renderer's theme color. | +| `gap_after_mm` | number | optional (default 0) | Extra vertical mm of space inserted below the group's last visible channel. | + +Channels not listed in any group are ignored — they will be derived but not +rendered. This allows a derivation to define more channels than any given +display surfaces. + +## Channel overrides (`channel_overrides{}`) + +Per-channel adjustments keyed by label. Any field at its default falls back +to the channel's group setting (color) or the renderer default. + +```json +"channel_overrides": { + "F7-Sp1": {"label": "F7-Sp1", "color": "#3a7ab8"}, + "Sp1-T3": {"label": "Sp1-T3", "color": "#3a7ab8", "gain": 1.5} +} +``` + +| field | type | meaning | +|---|---|---| +| `label` | string | Must equal the dict key. (Carried because `ChannelStyle` is also used standalone.) | +| `color` | string \| null | Trace color override. | +| `gap_after_mm` | number \| null | Extra space below this channel. Wins over the group's `gap_after_mm`. | +| `visible` | bool (default `true`) | If `false`, the channel is dropped from layout and the matrix row for it is skipped during rendering. | +| `gain` | number (default 1.0) | Dimensionless y-scale multiplier; combined multiplicatively with the renderer's global `yscale` / `sensitivity`. | +| `sensitivity` | number \| null | Absolute sensitivity in μV/mm. Overrides the renderer's global sensitivity for this channel. If both `gain` and `sensitivity` are set, `sensitivity` wins and `gain` is applied on top of it. Must be > 0. | +| `lf` | number \| null | Per-channel low-frequency cutoff (high-pass filter), Hz. `0` or `null` disables. | +| `hf` | number \| null | Per-channel high-frequency cutoff (low-pass filter), Hz. `0` or `null` disables. | +| `cal` | number \| null | Per-channel calibration amplitude in μV (used for per-channel scale bar / calibration pulse). Must be > 0. | +| `width` | number \| null | Per-channel trace stroke width in mm. Must be > 0. | + +> **Δ today**: `sensitivity`, `lf`, `hf`, `cal`, `width` are *captured* in +> the JSON profile and surfaced in the editor UI, but the SVG renderer +> currently uses only its global toolbar values. Per-channel wire-through +> in `stackplot_svg` is tracked separately. + +## End-to-end examples + +### 1. Simple bipolar chain (circle) + +```json +{ + "name": "circle", + "derivation": { + "type": "symbolic", + "channels": [ + {"label": "Fp1-F7", "diffpair": ["Fp1", "F7"]}, + {"label": "F7-T3", "diffpair": ["F7", "T3"]}, + {"label": "T3-T5", "diffpair": ["T3", "T5"]}, + {"label": "T5-O1", "diffpair": ["T5", "O1"]}, + {"label": "O1-O2", "diffpair": ["O1", "O2"]}, + {"label": "O2-T6", "diffpair": ["O2", "T6"]}, + {"label": "T6-T4", "diffpair": ["T6", "T4"]}, + {"label": "T4-F8", "diffpair": ["T4", "F8"]}, + {"label": "F8-Fp2", "diffpair": ["F8", "Fp2"]}, + {"label": "Fp2-Fp1", "diffpair": ["Fp2", "Fp1"]} + ] + }, + "groups": [ ... ] +} +``` + +### 2. Common-average reference (double_banana_avg) + +```json +{ + "name": "double_banana_avg", + "derivation": { + "type": "symbolic", + "virtual_channels": { + "AVG": { + "type": "mean", + "electrodes": ["Fp1","F7","T3","T5","Fp2","F8","T4","T6", + "F3","C3","P3","O1","F4","C4","P4","O2", + "Fz","Cz","Pz"] + } + }, + "channels": [ + {"label": "Fp1-AVG", "diffpair": ["Fp1", "AVG"]}, + {"label": "F7-AVG", "diffpair": ["F7", "AVG"]}, + ... + ] + }, + "groups": [ ... ] +} +``` + +Resolution for `Fp1-AVG` given `rec_labels = [Fp1, F7, T3, ...]` (19 of them): + +``` +AVG row = (1/19) * (Fp1 + F7 + T3 + ... + Pz) +Fp1-AVG = Fp1 - AVG + = (1 - 1/19) * Fp1 + (-1/19) * F7 + (-1/19) * T3 + ... + = (18/19) * Fp1 - (1/19) * (sum of others) +``` + +> **Δ today**: this differs from `CommonAvgRefMontageView` by a constant +> factor of N/(N−1) = 19/18 ≈ 1.056 (the existing class implements a +> leave-one-out form). Visually indistinguishable; rendered amplitudes +> differ by ~5%. + +### 3. Linked-ears reference (illustrative; no bundled profile yet) + +```json +{ + "name": "linked_ears", + "derivation": { + "type": "symbolic", + "virtual_channels": { + "LE": {"type": "mean", "electrodes": ["A1", "A2"]} + }, + "channels": [ + {"label": "Fp1-LE", "diffpair": ["Fp1", "LE"]}, + {"label": "Fp2-LE", "diffpair": ["Fp2", "LE"]} + ] + } +} +``` + +### 4. Custom weighted derivation (illustrative) + +A bipolar Laplacian for C3, weighted manually: + +```json +{ + "label": "C3-laplacian", + "sum_coefficients": { + "C3": 1.0, + "F3": -0.25, "P3": -0.25, "T3": -0.25, "Cz": -0.25 + } +} +``` + +## Implementation notes (proposal) + +> **Δ today**: this section describes intended changes that have not +> landed yet. Open in this dir: `montage_display.py` currently exposes +> `MontageDerivation` (matrix only). The symbolic form adds two new +> dataclasses and one router. + +Proposed Python types: + +```python +@dataclass +class VirtualElectrode: + type: str # "mean" | "weighted" + electrodes: list[str] = field(default=list) # for type=mean + weights: dict[str, float] = field(default=dict) # for type=weighted + + def resolve(self, rec_labels: list[str]) -> np.ndarray: ... + +@dataclass +class SymbolicChannel: + label: str + diffpair: list[str] | None = None + sum_coefficients: dict[str, float] | None = None + + def resolve(self, rec_labels, virtuals) -> np.ndarray: ... + +@dataclass +class SymbolicDerivation: + channels: list[SymbolicChannel] + virtual_channels: dict[str, VirtualElectrode] = field(default=dict) + reversed_polarity: bool = True + + def to_montage_view(self, rec_labels): ... +``` + +`MontageDisplay.derivation` becomes `MontageDerivation | SymbolicDerivation`. +JSON load dispatches on `derivation.type` (defaulting to `"matrix"` for +back-compat). + +## Authoring conventions + +- One profile per file. Filename stem matches `name` (e.g. `circle.json`). +- 2-space JSON indentation, trailing commas omitted. +- Channel labels use the same casing as the rec_labels they reference + (`Fp1-F7`, not `FP1-F7`). +- Virtual electrode names are uppercase by convention to keep them visually + distinct from electrode names (`AVG`, `LE`, `EAR`). +- Place spacers (`gap_after_mm`) only at boundaries clinicians actually + care about (chain transitions, left/right hemisphere transitions, etc.) — + not between every group. +- Prefer `derivation` (self-contained) over `derivation_ref` for new + profiles. `derivation_ref` remains supported for legacy / quick reuse of + the built-in `MontageView` classes. + +## The `trace` exception + +`derivation_ref: "trace"` is the canonical exception to the "prefer +symbolic" rule. The trace montage is the identity derivation: the matrix +is `I`, and the output channels are exactly `rec_labels` — so both the +dimension *and* the labels are determined by the recording at apply time, +not by anything authored ahead of time. A symbolic profile cannot express +this: `channels` is an authored list, but a trace profile's channel list +is the recording's channel list. + +`trace` therefore stays Python-only. Reference it from a profile as: + +```json +{ + "name": "trace", + "derivation_ref": "trace", + "groups": [] +} +``` + +If `groups` is empty (or omitted), the renderer is expected to fall back +to a default "one group containing every derived channel" layout. See +`TraceMontageView` in `eegvis/montageview.py`. diff --git a/eegvis/displays/__init__.py b/eegvis/displays/__init__.py new file mode 100644 index 0000000..a55dc96 --- /dev/null +++ b/eegvis/displays/__init__.py @@ -0,0 +1,111 @@ +"""Bundled and user MontageDisplay profiles. + +Two locations are searched: + +- **System / bundled** — JSON files shipped next to this module in + ``eegvis/displays/``. Curated profiles part of the package. +- **User** — JSON files under ``~/.eegvis/displays/`` (override with + ``EEGVIS_USER_DISPLAYS_DIR``). Created by the in-app montage editor and + loaded the same way as bundled profiles. User profiles take precedence + over bundled ones if a name collides. + +Each ``*.json`` is a serialized :class:`eegvis.montage_display.MontageDisplay` +matching the schema in ``SCHEMA.md`` / +``montage_display.schema.json``. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Dict, List, Optional + +from ..montage_display import MontageDisplay + +SYSTEM_DIR = Path(__file__).resolve().parent + + +def user_dir() -> Path: + """Return the user-displays directory. May not exist yet.""" + override = os.environ.get("EEGVIS_USER_DISPLAYS_DIR") + if override: + return Path(override).expanduser() + return Path("~/.eegvis/displays").expanduser() + + +def _is_profile(p: Path) -> bool: + return p.suffix == ".json" and not p.name.endswith(".schema.json") + + +def list_displays(scope: str = "all") -> List[str]: + """Return the names of available MontageDisplay profiles. + + Args: + scope: ``"system"`` (bundled only), ``"user"`` (user dir only), + or ``"all"`` (both; user overrides system on name collisions). + """ + if scope == "system": + return sorted(p.stem for p in SYSTEM_DIR.glob("*.json") if _is_profile(p)) + if scope == "user": + ud = user_dir() + if not ud.exists(): + return [] + return sorted(p.stem for p in ud.glob("*.json") if _is_profile(p)) + if scope == "all": + names = set(list_displays("system")) + names.update(list_displays("user")) + return sorted(names) + raise ValueError(f"unknown scope {scope!r}; expected system/user/all") + + +def list_displays_by_scope() -> Dict[str, List[str]]: + """Return ``{"system": [...], "user": [...]}`` for the editor UI.""" + return {"system": list_displays("system"), "user": list_displays("user")} + + +def find_display_path(name: str, scope: Optional[str] = None) -> Path: + """Resolve a profile name to a path. Raises KeyError if not found. + + If ``scope`` is None, search user first then system. + """ + if scope == "user": + path = user_dir() / f"{name}.json" + if not path.exists(): + raise KeyError(f"unknown user display {name!r}") + return path + if scope == "system": + path = SYSTEM_DIR / f"{name}.json" + if not path.exists(): + raise KeyError(f"unknown bundled display {name!r}") + return path + # default: user wins over system + upath = user_dir() / f"{name}.json" + if upath.exists(): + return upath + spath = SYSTEM_DIR / f"{name}.json" + if spath.exists(): + return spath + raise KeyError(f"unknown display {name!r}; available: {list_displays('all')}") + + +def load_display(name: str, scope: Optional[str] = None) -> MontageDisplay: + """Load a MontageDisplay by name from user-or-system.""" + return MontageDisplay.load(find_display_path(name, scope=scope)) + + +def save_user_display(display: MontageDisplay, name: Optional[str] = None) -> Path: + """Write a MontageDisplay to the user directory and return the path. + + Uses ``display.name`` as the filename stem unless ``name`` is given. + Creates the user directory if needed. A user profile with the same name + as a bundled system profile shadows it (user wins in + :func:`load_display`); use ``list_displays_by_scope`` to see both lists. + """ + stem = name or display.name + if not stem: + raise ValueError("MontageDisplay.name is empty; pass a name explicitly") + ud = user_dir() + ud.mkdir(parents=True, exist_ok=True) + path = ud / f"{stem}.json" + display.save(path) + return path diff --git a/eegvis/displays/circle.json b/eegvis/displays/circle.json new file mode 100644 index 0000000..06e3e2a --- /dev/null +++ b/eegvis/displays/circle.json @@ -0,0 +1,122 @@ +{ + "$schema": "./montage_display.schema.json", + "name": "circle", + "description": "Circumferential (circle) montage that walks counter-clockwise around the head perimeter (10 channels). Starts at Fp1 going down the left side, crosses the back at O1-O2, comes up the right side, and closes at the front with Fp2-Fp1. Spacers mark the two midline crossings (back and front) where left meets right.", + "derivation": { + "type": "symbolic", + "reversed_polarity": true, + "channels": [ + { + "label": "Fp1-F7", + "diffpair": [ + "Fp1", + "F7" + ] + }, + { + "label": "F7-T3", + "diffpair": [ + "F7", + "T3" + ] + }, + { + "label": "T3-T5", + "diffpair": [ + "T3", + "T5" + ] + }, + { + "label": "T5-O1", + "diffpair": [ + "T5", + "O1" + ] + }, + { + "label": "O1-O2", + "diffpair": [ + "O1", + "O2" + ] + }, + { + "label": "O2-T6", + "diffpair": [ + "O2", + "T6" + ] + }, + { + "label": "T6-T4", + "diffpair": [ + "T6", + "T4" + ] + }, + { + "label": "T4-F8", + "diffpair": [ + "T4", + "F8" + ] + }, + { + "label": "F8-Fp2", + "diffpair": [ + "F8", + "Fp2" + ] + }, + { + "label": "Fp2-Fp1", + "diffpair": [ + "Fp2", + "Fp1" + ] + } + ] + }, + "groups": [ + { + "name": "left_perimeter", + "channels": [ + "Fp1-F7", + "F7-T3", + "T3-T5", + "T5-O1" + ], + "color": "#1f4e79", + "gap_after_mm": 4.0 + }, + { + "name": "back_crossing", + "channels": [ + "O1-O2" + ], + "color": "#000000", + "gap_after_mm": 4.0 + }, + { + "name": "right_perimeter", + "channels": [ + "O2-T6", + "T6-T4", + "T4-F8", + "F8-Fp2" + ], + "color": "#a8323e", + "gap_after_mm": 4.0 + }, + { + "name": "front_crossing", + "channels": [ + "Fp2-Fp1" + ], + "color": "#000000", + "gap_after_mm": 0.0 + } + ], + "channel_overrides": {} +} diff --git a/eegvis/displays/double_banana.json b/eegvis/displays/double_banana.json new file mode 100644 index 0000000..3b1054b --- /dev/null +++ b/eegvis/displays/double_banana.json @@ -0,0 +1,193 @@ +{ + "$schema": "./montage_display.schema.json", + "name": "double_banana", + "description": "Standard 18-channel double-banana display. Channels are laid out top-down as left temporal chain, left parasagittal chain, midline, right parasagittal chain, right temporal chain. Extra spacers are inserted at the two left/right transitions (left chains -> midline and midline -> right chains) so the hemispheres read as visually distinct banks.", + "derivation": { + "type": "symbolic", + "reversed_polarity": true, + "channels": [ + { + "label": "Fp1-F7", + "diffpair": [ + "Fp1", + "F7" + ] + }, + { + "label": "F7-T3", + "diffpair": [ + "F7", + "T3" + ] + }, + { + "label": "T3-T5", + "diffpair": [ + "T3", + "T5" + ] + }, + { + "label": "T5-O1", + "diffpair": [ + "T5", + "O1" + ] + }, + { + "label": "Fp2-F8", + "diffpair": [ + "Fp2", + "F8" + ] + }, + { + "label": "F8-T4", + "diffpair": [ + "F8", + "T4" + ] + }, + { + "label": "T4-T6", + "diffpair": [ + "T4", + "T6" + ] + }, + { + "label": "T6-O2", + "diffpair": [ + "T6", + "O2" + ] + }, + { + "label": "Fp1-F3", + "diffpair": [ + "Fp1", + "F3" + ] + }, + { + "label": "F3-C3", + "diffpair": [ + "F3", + "C3" + ] + }, + { + "label": "C3-P3", + "diffpair": [ + "C3", + "P3" + ] + }, + { + "label": "P3-O1", + "diffpair": [ + "P3", + "O1" + ] + }, + { + "label": "Fp2-F4", + "diffpair": [ + "Fp2", + "F4" + ] + }, + { + "label": "F4-C4", + "diffpair": [ + "F4", + "C4" + ] + }, + { + "label": "C4-P4", + "diffpair": [ + "C4", + "P4" + ] + }, + { + "label": "P4-O2", + "diffpair": [ + "P4", + "O2" + ] + }, + { + "label": "Fz-Cz", + "diffpair": [ + "Fz", + "Cz" + ] + }, + { + "label": "Cz-Pz", + "diffpair": [ + "Cz", + "Pz" + ] + } + ] + }, + "groups": [ + { + "name": "left_temporal", + "channels": [ + "Fp1-F7", + "F7-T3", + "T3-T5", + "T5-O1" + ], + "color": "#1f4e79", + "gap_after_mm": 2.0 + }, + { + "name": "left_parasagittal", + "channels": [ + "Fp1-F3", + "F3-C3", + "C3-P3", + "P3-O1" + ], + "color": "#1f4e79", + "gap_after_mm": 5.0 + }, + { + "name": "midline", + "channels": [ + "Fz-Cz", + "Cz-Pz" + ], + "color": "#000000", + "gap_after_mm": 5.0 + }, + { + "name": "right_parasagittal", + "channels": [ + "Fp2-F4", + "F4-C4", + "C4-P4", + "P4-O2" + ], + "color": "#a8323e", + "gap_after_mm": 2.0 + }, + { + "name": "right_temporal", + "channels": [ + "Fp2-F8", + "F8-T4", + "T4-T6", + "T6-O2" + ], + "color": "#a8323e", + "gap_after_mm": 0.0 + } + ], + "channel_overrides": {} +} diff --git a/eegvis/displays/double_banana_avg.json b/eegvis/displays/double_banana_avg.json new file mode 100644 index 0000000..5832c94 --- /dev/null +++ b/eegvis/displays/double_banana_avg.json @@ -0,0 +1,227 @@ +{ + "$schema": "./montage_display.schema.json", + "name": "double_banana_avg", + "description": "Average-reference montage that uses the same electrode set as double banana but references every channel to the common average instead of forming bipolar chains. Channels are grouped in the same clinical bands as double_banana (LT, LL, midline, RR, RT) with the same blue/black/red color coding and large spacers at the two left/right transitions around the midline.", + "derivation": { + "type": "symbolic", + "reversed_polarity": true, + "virtual_channels": { + "AVG": { + "type": "mean", + "electrodes": [ + "Fp1", + "F7", + "T3", + "T5", + "Fp2", + "F8", + "T4", + "T6", + "F3", + "C3", + "P3", + "O1", + "F4", + "C4", + "P4", + "O2", + "Fz", + "Cz", + "Pz" + ] + } + }, + "channels": [ + { + "label": "Fp1-AVG", + "diffpair": [ + "Fp1", + "AVG" + ] + }, + { + "label": "F7-AVG", + "diffpair": [ + "F7", + "AVG" + ] + }, + { + "label": "T3-AVG", + "diffpair": [ + "T3", + "AVG" + ] + }, + { + "label": "T5-AVG", + "diffpair": [ + "T5", + "AVG" + ] + }, + { + "label": "F3-AVG", + "diffpair": [ + "F3", + "AVG" + ] + }, + { + "label": "C3-AVG", + "diffpair": [ + "C3", + "AVG" + ] + }, + { + "label": "P3-AVG", + "diffpair": [ + "P3", + "AVG" + ] + }, + { + "label": "O1-AVG", + "diffpair": [ + "O1", + "AVG" + ] + }, + { + "label": "Fz-AVG", + "diffpair": [ + "Fz", + "AVG" + ] + }, + { + "label": "Cz-AVG", + "diffpair": [ + "Cz", + "AVG" + ] + }, + { + "label": "Pz-AVG", + "diffpair": [ + "Pz", + "AVG" + ] + }, + { + "label": "F4-AVG", + "diffpair": [ + "F4", + "AVG" + ] + }, + { + "label": "C4-AVG", + "diffpair": [ + "C4", + "AVG" + ] + }, + { + "label": "P4-AVG", + "diffpair": [ + "P4", + "AVG" + ] + }, + { + "label": "O2-AVG", + "diffpair": [ + "O2", + "AVG" + ] + }, + { + "label": "Fp2-AVG", + "diffpair": [ + "Fp2", + "AVG" + ] + }, + { + "label": "F8-AVG", + "diffpair": [ + "F8", + "AVG" + ] + }, + { + "label": "T4-AVG", + "diffpair": [ + "T4", + "AVG" + ] + }, + { + "label": "T6-AVG", + "diffpair": [ + "T6", + "AVG" + ] + } + ] + }, + "groups": [ + { + "name": "left_temporal", + "channels": [ + "Fp1-AVG", + "F7-AVG", + "T3-AVG", + "T5-AVG" + ], + "color": "#1f4e79", + "gap_after_mm": 2.0 + }, + { + "name": "left_parasagittal", + "channels": [ + "F3-AVG", + "C3-AVG", + "P3-AVG", + "O1-AVG" + ], + "color": "#1f4e79", + "gap_after_mm": 5.0 + }, + { + "name": "midline", + "channels": [ + "Fz-AVG", + "Cz-AVG", + "Pz-AVG" + ], + "color": "#000000", + "gap_after_mm": 5.0 + }, + { + "name": "right_parasagittal", + "channels": [ + "F4-AVG", + "C4-AVG", + "P4-AVG", + "O2-AVG" + ], + "color": "#a8323e", + "gap_after_mm": 2.0 + }, + { + "name": "right_temporal", + "channels": [ + "Fp2-AVG", + "F8-AVG", + "T4-AVG", + "T6-AVG" + ], + "color": "#a8323e", + "gap_after_mm": 0.0 + } + ], + "channel_overrides": {} +} diff --git a/eegvis/displays/double_banana_paired.json b/eegvis/displays/double_banana_paired.json new file mode 100644 index 0000000..4738853 --- /dev/null +++ b/eegvis/displays/double_banana_paired.json @@ -0,0 +1,193 @@ +{ + "$schema": "./montage_display.schema.json", + "name": "double_banana_paired", + "description": "Alternative double-banana layout that interleaves left/right chains so a clinician can compare hemispheres at the same vertical position. Left temporal sits directly above right temporal, then left parasagittal above right parasagittal, then midline at the bottom. Spacers are placed between each left->right transition.", + "derivation": { + "type": "symbolic", + "reversed_polarity": true, + "channels": [ + { + "label": "Fp1-F7", + "diffpair": [ + "Fp1", + "F7" + ] + }, + { + "label": "F7-T3", + "diffpair": [ + "F7", + "T3" + ] + }, + { + "label": "T3-T5", + "diffpair": [ + "T3", + "T5" + ] + }, + { + "label": "T5-O1", + "diffpair": [ + "T5", + "O1" + ] + }, + { + "label": "Fp2-F8", + "diffpair": [ + "Fp2", + "F8" + ] + }, + { + "label": "F8-T4", + "diffpair": [ + "F8", + "T4" + ] + }, + { + "label": "T4-T6", + "diffpair": [ + "T4", + "T6" + ] + }, + { + "label": "T6-O2", + "diffpair": [ + "T6", + "O2" + ] + }, + { + "label": "Fp1-F3", + "diffpair": [ + "Fp1", + "F3" + ] + }, + { + "label": "F3-C3", + "diffpair": [ + "F3", + "C3" + ] + }, + { + "label": "C3-P3", + "diffpair": [ + "C3", + "P3" + ] + }, + { + "label": "P3-O1", + "diffpair": [ + "P3", + "O1" + ] + }, + { + "label": "Fp2-F4", + "diffpair": [ + "Fp2", + "F4" + ] + }, + { + "label": "F4-C4", + "diffpair": [ + "F4", + "C4" + ] + }, + { + "label": "C4-P4", + "diffpair": [ + "C4", + "P4" + ] + }, + { + "label": "P4-O2", + "diffpair": [ + "P4", + "O2" + ] + }, + { + "label": "Fz-Cz", + "diffpair": [ + "Fz", + "Cz" + ] + }, + { + "label": "Cz-Pz", + "diffpair": [ + "Cz", + "Pz" + ] + } + ] + }, + "groups": [ + { + "name": "left_temporal", + "channels": [ + "Fp1-F7", + "F7-T3", + "T3-T5", + "T5-O1" + ], + "color": "#1f4e79", + "gap_after_mm": 4.0 + }, + { + "name": "right_temporal", + "channels": [ + "Fp2-F8", + "F8-T4", + "T4-T6", + "T6-O2" + ], + "color": "#a8323e", + "gap_after_mm": 6.0 + }, + { + "name": "left_parasagittal", + "channels": [ + "Fp1-F3", + "F3-C3", + "C3-P3", + "P3-O1" + ], + "color": "#1f4e79", + "gap_after_mm": 4.0 + }, + { + "name": "right_parasagittal", + "channels": [ + "Fp2-F4", + "F4-C4", + "C4-P4", + "P4-O2" + ], + "color": "#a8323e", + "gap_after_mm": 6.0 + }, + { + "name": "midline", + "channels": [ + "Fz-Cz", + "Cz-Pz" + ], + "color": "#000000", + "gap_after_mm": 0.0 + } + ], + "channel_overrides": {} +} diff --git a/eegvis/displays/montage_display.schema.json b/eegvis/displays/montage_display.schema.json new file mode 100644 index 0000000..f7ffc2d --- /dev/null +++ b/eegvis/displays/montage_display.schema.json @@ -0,0 +1,213 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/eegml/eegvis/montage_display.schema.json", + "title": "MontageDisplay profile", + "description": "Schema for eegvis MontageDisplay JSON profiles. See SCHEMA.md for prose.", + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "$schema": {"type": "string"}, + "name": {"type": "string", "minLength": 1}, + "description": {"type": "string"}, + "derivation_ref": {"type": ["string", "null"]}, + "derivation": {"$ref": "#/$defs/Derivation"}, + "groups": { + "type": "array", + "items": {"$ref": "#/$defs/ChannelGroup"} + }, + "channel_overrides": { + "type": "object", + "additionalProperties": {"$ref": "#/$defs/ChannelStyle"} + } + }, + "oneOf": [ + { + "title": "named built-in derivation", + "required": ["derivation_ref"], + "properties": { + "derivation_ref": {"type": "string", "minLength": 1} + }, + "not": { + "anyOf": [ + {"required": ["derivation"]} + ] + } + }, + { + "title": "self-contained derivation", + "required": ["derivation"], + "properties": { + "derivation_ref": {"type": "null"} + } + } + ], + "$defs": { + "Derivation": { + "description": "Either a symbolic derivation or an explicit matrix.", + "oneOf": [ + {"$ref": "#/$defs/SymbolicDerivation"}, + {"$ref": "#/$defs/MatrixDerivation"} + ] + }, + "SymbolicDerivation": { + "type": "object", + "additionalProperties": false, + "required": ["type", "channels"], + "properties": { + "type": {"const": "symbolic"}, + "reversed_polarity": {"type": "boolean"}, + "virtual_channels": { + "type": "object", + "additionalProperties": {"$ref": "#/$defs/VirtualElectrode"} + }, + "channels": { + "type": "array", + "minItems": 1, + "items": {"$ref": "#/$defs/SymbolicChannel"} + } + } + }, + "MatrixDerivation": { + "type": "object", + "description": "Explicit matrix form. The 'type' field is optional for backward compatibility; absent means matrix.", + "additionalProperties": false, + "required": ["montage_labels", "rec_labels", "matrix"], + "properties": { + "type": {"const": "matrix"}, + "reversed_polarity": {"type": "boolean"}, + "montage_labels": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1 + }, + "rec_labels": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1 + }, + "matrix": { + "type": "array", + "items": { + "type": "array", + "items": {"type": "number"} + } + } + } + }, + "VirtualElectrode": { + "description": "Named linear combination of physical electrodes.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "electrodes"], + "properties": { + "type": {"const": "mean"}, + "electrodes": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "weights"], + "properties": { + "type": {"const": "weighted"}, + "weights": { + "type": "object", + "minProperties": 1, + "additionalProperties": {"type": "number"} + } + } + } + ] + }, + "SymbolicChannel": { + "type": "object", + "additionalProperties": false, + "required": ["label"], + "properties": { + "label": {"type": "string", "minLength": 1}, + "diffpair": { + "description": "Bipolar shorthand: [a, b] -> +1 on a, -1 on b.", + "type": "array", + "items": {"type": "string", "minLength": 1}, + "minItems": 2, + "maxItems": 2 + }, + "sum_coefficients": { + "description": "General linear combination: name -> weight.", + "type": "object", + "minProperties": 1, + "additionalProperties": {"type": "number"} + } + }, + "oneOf": [ + {"required": ["diffpair"], "not": {"required": ["sum_coefficients"]}}, + {"required": ["sum_coefficients"], "not": {"required": ["diffpair"]}} + ] + }, + "ChannelGroup": { + "type": "object", + "additionalProperties": false, + "required": ["name", "channels"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "channels": { + "type": "array", + "items": {"type": "string", "minLength": 1} + }, + "color": {"$ref": "#/$defs/ColorOrNull"}, + "gap_after_mm": {"type": "number", "minimum": 0} + } + }, + "ChannelStyle": { + "type": "object", + "additionalProperties": false, + "required": ["label"], + "properties": { + "label": {"type": "string", "minLength": 1}, + "color": {"$ref": "#/$defs/ColorOrNull"}, + "gap_after_mm": {"type": ["number", "null"], "minimum": 0}, + "visible": {"type": "boolean"}, + "gain": {"type": "number"}, + "sensitivity": { + "description": "Per-channel sensitivity (μV/mm). Overrides the renderer's global sensitivity.", + "type": ["number", "null"], + "exclusiveMinimum": 0 + }, + "lf": { + "description": "Per-channel low-frequency cutoff / high-pass filter (Hz). 0 or null disables.", + "type": ["number", "null"], + "minimum": 0 + }, + "hf": { + "description": "Per-channel high-frequency cutoff / low-pass filter (Hz). 0 or null disables.", + "type": ["number", "null"], + "minimum": 0 + }, + "cal": { + "description": "Per-channel calibration amplitude (μV).", + "type": ["number", "null"], + "exclusiveMinimum": 0 + }, + "width": { + "description": "Per-channel trace stroke width (mm).", + "type": ["number", "null"], + "exclusiveMinimum": 0 + } + } + }, + "ColorOrNull": { + "description": "CSS color (hex like #ab12cd, rgb(), or named) or null for theme default.", + "oneOf": [ + {"type": "null"}, + {"type": "string", "minLength": 1} + ] + } + } +} diff --git a/eegvis/displays/neonatal.json b/eegvis/displays/neonatal.json new file mode 100644 index 0000000..46ed276 --- /dev/null +++ b/eegvis/displays/neonatal.json @@ -0,0 +1,191 @@ +{ + "$schema": "./montage_display.schema.json", + "name": "neonatal", + "description": "Modified 10-20 neonatal montage based on Shellhaas (2011) ACNS guideline 13, plus an occipital traversal (T3-O1 -> O1-O2 -> O2-T4). 16 channels arranged as paired temporal then paired central long chains, followed by a coronal row, midline, and a back occipital traversal. Spacers fall at each left/right transition.", + "derivation": { + "type": "symbolic", + "reversed_polarity": true, + "channels": [ + { + "label": "Fp1-T3", + "diffpair": [ + "Fp1", + "T3" + ] + }, + { + "label": "T3-O1", + "diffpair": [ + "T3", + "O1" + ] + }, + { + "label": "Fp2-T4", + "diffpair": [ + "Fp2", + "T4" + ] + }, + { + "label": "T4-O2", + "diffpair": [ + "T4", + "O2" + ] + }, + { + "label": "Fp1-C3", + "diffpair": [ + "Fp1", + "C3" + ] + }, + { + "label": "C3-O1", + "diffpair": [ + "C3", + "O1" + ] + }, + { + "label": "Fp2-C4", + "diffpair": [ + "Fp2", + "C4" + ] + }, + { + "label": "C4-O2", + "diffpair": [ + "C4", + "O2" + ] + }, + { + "label": "T3-C3", + "diffpair": [ + "T3", + "C3" + ] + }, + { + "label": "C3-Cz", + "diffpair": [ + "C3", + "Cz" + ] + }, + { + "label": "Cz-C4", + "diffpair": [ + "Cz", + "C4" + ] + }, + { + "label": "C4-T4", + "diffpair": [ + "C4", + "T4" + ] + }, + { + "label": "Fz-Cz", + "diffpair": [ + "Fz", + "Cz" + ] + }, + { + "label": "Cz-Pz", + "diffpair": [ + "Cz", + "Pz" + ] + }, + { + "label": "O1-O2", + "diffpair": [ + "O1", + "O2" + ] + }, + { + "label": "O2-T4", + "diffpair": [ + "O2", + "T4" + ] + } + ] + }, + "groups": [ + { + "name": "left_temporal", + "channels": [ + "Fp1-T3", + "T3-O1" + ], + "color": "#1f4e79", + "gap_after_mm": 2.0 + }, + { + "name": "right_temporal", + "channels": [ + "Fp2-T4", + "T4-O2" + ], + "color": "#a8323e", + "gap_after_mm": 5.0 + }, + { + "name": "left_central", + "channels": [ + "Fp1-C3", + "C3-O1" + ], + "color": "#1f4e79", + "gap_after_mm": 2.0 + }, + { + "name": "right_central", + "channels": [ + "Fp2-C4", + "C4-O2" + ], + "color": "#a8323e", + "gap_after_mm": 5.0 + }, + { + "name": "coronal", + "channels": [ + "T3-C3", + "C3-Cz", + "Cz-C4", + "C4-T4" + ], + "color": "#000000", + "gap_after_mm": 4.0 + }, + { + "name": "midline", + "channels": [ + "Fz-Cz", + "Cz-Pz" + ], + "color": "#000000", + "gap_after_mm": 4.0 + }, + { + "name": "back", + "channels": [ + "O1-O2", + "O2-T4" + ], + "color": "#000000", + "gap_after_mm": 0.0 + } + ], + "channel_overrides": {} +} diff --git a/eegvis/displays/tcp.json b/eegvis/displays/tcp.json new file mode 100644 index 0000000..96a450c --- /dev/null +++ b/eegvis/displays/tcp.json @@ -0,0 +1,209 @@ +{ + "$schema": "./montage_display.schema.json", + "name": "tcp", + "description": "Temporal Central Parasagittal montage (20 channels). Left chains on top, central coronal row through the midline, right chains on the bottom. The coronal row (A1-T3 -> T4-A2) bridges the hemispheres, so the spacers are placed on either side of it to mark the left/right transitions.", + "derivation": { + "type": "symbolic", + "reversed_polarity": true, + "channels": [ + { + "label": "Fp1-F7", + "diffpair": [ + "Fp1", + "F7" + ] + }, + { + "label": "F7-T3", + "diffpair": [ + "F7", + "T3" + ] + }, + { + "label": "T3-T5", + "diffpair": [ + "T3", + "T5" + ] + }, + { + "label": "T5-O1", + "diffpair": [ + "T5", + "O1" + ] + }, + { + "label": "Fp2-F8", + "diffpair": [ + "Fp2", + "F8" + ] + }, + { + "label": "F8-T4", + "diffpair": [ + "F8", + "T4" + ] + }, + { + "label": "T4-T6", + "diffpair": [ + "T4", + "T6" + ] + }, + { + "label": "T6-O2", + "diffpair": [ + "T6", + "O2" + ] + }, + { + "label": "A1-T3", + "diffpair": [ + "A1", + "T3" + ] + }, + { + "label": "T3-C3", + "diffpair": [ + "T3", + "C3" + ] + }, + { + "label": "C3-Cz", + "diffpair": [ + "C3", + "Cz" + ] + }, + { + "label": "Cz-C4", + "diffpair": [ + "Cz", + "C4" + ] + }, + { + "label": "C4-T4", + "diffpair": [ + "C4", + "T4" + ] + }, + { + "label": "T4-A2", + "diffpair": [ + "T4", + "A2" + ] + }, + { + "label": "Fp1-F3", + "diffpair": [ + "Fp1", + "F3" + ] + }, + { + "label": "F3-C3", + "diffpair": [ + "F3", + "C3" + ] + }, + { + "label": "C3-P3", + "diffpair": [ + "C3", + "P3" + ] + }, + { + "label": "Fp2-F4", + "diffpair": [ + "Fp2", + "F4" + ] + }, + { + "label": "F4-C4", + "diffpair": [ + "F4", + "C4" + ] + }, + { + "label": "C4-P4", + "diffpair": [ + "C4", + "P4" + ] + } + ] + }, + "groups": [ + { + "name": "left_temporal", + "channels": [ + "Fp1-F7", + "F7-T3", + "T3-T5", + "T5-O1" + ], + "color": "#1f4e79", + "gap_after_mm": 2.0 + }, + { + "name": "left_parasagittal", + "channels": [ + "Fp1-F3", + "F3-C3", + "C3-P3" + ], + "color": "#1f4e79", + "gap_after_mm": 5.0 + }, + { + "name": "coronal", + "channels": [ + "A1-T3", + "T3-C3", + "C3-Cz", + "Cz-C4", + "C4-T4", + "T4-A2" + ], + "color": "#000000", + "gap_after_mm": 5.0 + }, + { + "name": "right_parasagittal", + "channels": [ + "Fp2-F4", + "F4-C4", + "C4-P4" + ], + "color": "#a8323e", + "gap_after_mm": 2.0 + }, + { + "name": "right_temporal", + "channels": [ + "Fp2-F8", + "F8-T4", + "T4-T6", + "T6-O2" + ], + "color": "#a8323e", + "gap_after_mm": 0.0 + } + ], + "channel_overrides": {} +} diff --git a/eegvis/displays/true_sphenoidal.json b/eegvis/displays/true_sphenoidal.json new file mode 100644 index 0000000..4c30fbd --- /dev/null +++ b/eegvis/displays/true_sphenoidal.json @@ -0,0 +1,226 @@ +{ + "$schema": "./montage_display.schema.json", + "name": "true_sphenoidal", + "description": "True sphenoidal montage: a double-banana variant that threads the surgically placed sphenoidal electrodes Sp1 and Sp2 into the anterior temporal chains so mesial-temporal activity is visible. 'True' distinguishes this from anterior-temporal proxy montages that use surface positions like FT9/FT10 in place of real sphenoidal leads. Each temporal chain becomes 5 channels (Fp1-F7, F7-Sp1, Sp1-T3, T3-T5, T5-O1 and mirror). Sphenoidal pairs (F7-Sp1, Sp1-T3 and F8-Sp2, Sp2-T4) are tinted distinctly. Spacers fall at the left/right transitions around the midline. Requires Sp1 and Sp2 in rec_labels.", + "derivation": { + "type": "symbolic", + "reversed_polarity": true, + "channels": [ + { + "label": "Fp1-F7", + "diffpair": [ + "Fp1", + "F7" + ] + }, + { + "label": "F7-Sp1", + "diffpair": [ + "F7", + "Sp1" + ] + }, + { + "label": "Sp1-T3", + "diffpair": [ + "Sp1", + "T3" + ] + }, + { + "label": "T3-T5", + "diffpair": [ + "T3", + "T5" + ] + }, + { + "label": "T5-O1", + "diffpair": [ + "T5", + "O1" + ] + }, + { + "label": "Fp2-F8", + "diffpair": [ + "Fp2", + "F8" + ] + }, + { + "label": "F8-Sp2", + "diffpair": [ + "F8", + "Sp2" + ] + }, + { + "label": "Sp2-T4", + "diffpair": [ + "Sp2", + "T4" + ] + }, + { + "label": "T4-T6", + "diffpair": [ + "T4", + "T6" + ] + }, + { + "label": "T6-O2", + "diffpair": [ + "T6", + "O2" + ] + }, + { + "label": "Fp1-F3", + "diffpair": [ + "Fp1", + "F3" + ] + }, + { + "label": "F3-C3", + "diffpair": [ + "F3", + "C3" + ] + }, + { + "label": "C3-P3", + "diffpair": [ + "C3", + "P3" + ] + }, + { + "label": "P3-O1", + "diffpair": [ + "P3", + "O1" + ] + }, + { + "label": "Fp2-F4", + "diffpair": [ + "Fp2", + "F4" + ] + }, + { + "label": "F4-C4", + "diffpair": [ + "F4", + "C4" + ] + }, + { + "label": "C4-P4", + "diffpair": [ + "C4", + "P4" + ] + }, + { + "label": "P4-O2", + "diffpair": [ + "P4", + "O2" + ] + }, + { + "label": "Fz-Cz", + "diffpair": [ + "Fz", + "Cz" + ] + }, + { + "label": "Cz-Pz", + "diffpair": [ + "Cz", + "Pz" + ] + } + ] + }, + "groups": [ + { + "name": "left_temporal", + "channels": [ + "Fp1-F7", + "F7-Sp1", + "Sp1-T3", + "T3-T5", + "T5-O1" + ], + "color": "#1f4e79", + "gap_after_mm": 2.0 + }, + { + "name": "left_parasagittal", + "channels": [ + "Fp1-F3", + "F3-C3", + "C3-P3", + "P3-O1" + ], + "color": "#1f4e79", + "gap_after_mm": 5.0 + }, + { + "name": "midline", + "channels": [ + "Fz-Cz", + "Cz-Pz" + ], + "color": "#000000", + "gap_after_mm": 5.0 + }, + { + "name": "right_parasagittal", + "channels": [ + "Fp2-F4", + "F4-C4", + "C4-P4", + "P4-O2" + ], + "color": "#a8323e", + "gap_after_mm": 2.0 + }, + { + "name": "right_temporal", + "channels": [ + "Fp2-F8", + "F8-Sp2", + "Sp2-T4", + "T4-T6", + "T6-O2" + ], + "color": "#a8323e", + "gap_after_mm": 0.0 + } + ], + "channel_overrides": { + "F7-Sp1": { + "label": "F7-Sp1", + "color": "#3a7ab8" + }, + "Sp1-T3": { + "label": "Sp1-T3", + "color": "#3a7ab8" + }, + "F8-Sp2": { + "label": "F8-Sp2", + "color": "#d9686f" + }, + "Sp2-T4": { + "label": "Sp2-T4", + "color": "#d9686f" + } + } +} diff --git a/eegvis/montage_display.py b/eegvis/montage_display.py new file mode 100644 index 0000000..eef88db --- /dev/null +++ b/eegvis/montage_display.py @@ -0,0 +1,719 @@ +# -*- coding: utf-8 -*- +"""Montage display profiles: derivation + presentation (groups, gaps, colors). + +A ``MontageDisplay`` bundles three things that have historically lived in +separate places: + +1. **Which derivation to apply** — a built-in by name + (``derivation_ref="double_banana"``), a self-contained matrix block + (:class:`MontageDerivation`), or a portable symbolic linear-combination + block (:class:`SymbolicDerivation`) — see ``displays/SCHEMA.md``. +2. **How to group and order the derived channels** — a list of + :class:`ChannelGroup` with names, default colors, and a ``gap_after_mm`` + that produces an extra visual break after the group. +3. **Per-channel overrides** — :class:`ChannelStyle` entries keyed by label + for color, extra gap, visibility, and gain. + +Display profiles round-trip through JSON via :meth:`MontageDisplay.save` and +:meth:`MontageDisplay.load`. +""" + +from __future__ import annotations + +import dataclasses +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Dict, List, Optional, Union + +import numpy as np + + +@dataclass +class ChannelStyle: + """Per-channel display override. + + Any field left at its default falls back to the channel's group setting + (or to the renderer's global default — e.g. the viewer-toolbar + sensitivity). + + Clinical-style per-channel attributes: + + - ``sensitivity``: absolute sensitivity in μV/mm. Overrides the + renderer's global sensitivity for this channel. + - ``lf``: low-frequency cutoff (high-pass filter), Hz. + - ``hf``: high-frequency cutoff (low-pass filter), Hz. + - ``cal``: calibration amplitude, μV (used for the per-channel scale + bar / calibration pulse). + - ``width``: trace stroke width in mm. + - ``gain``: dimensionless multiplier on top of the global / sensitivity + scaling. Distinct from ``sensitivity``: ``gain`` is relative, + ``sensitivity`` is absolute. If both are set, ``sensitivity`` wins + and ``gain`` is composed on top of it. + + These are *captured* in the JSON profile and surfaced in the editor; + the SVG renderer does not yet honor them per-channel (it uses the + global toolbar values). Wiring them through ``stackplot_svg`` is a + separate change tracked in ``TODO.md``. + """ + + label: str + color: Optional[str] = None + gap_after_mm: Optional[float] = None + visible: bool = True + gain: float = 1.0 + sensitivity: Optional[float] = None + lf: Optional[float] = None + hf: Optional[float] = None + cal: Optional[float] = None + width: Optional[float] = None + + +@dataclass +class ChannelGroup: + """A contiguous, named group of channels with shared default styling.""" + + name: str + channels: List[str] = field(default_factory=list) + color: Optional[str] = None + gap_after_mm: float = 0.0 + + +@dataclass +class MontageDerivation: + """Self-contained linear derivation: M output channels from N inputs. + + ``matrix`` is shape (len(montage_labels), len(rec_labels)). The matrix is + stored as-is — if a polarity flip was applied when building the original + MontageView, that flip should already be baked into ``matrix``. The + ``reversed_polarity`` flag is preserved as metadata only. + """ + + montage_labels: List[str] + rec_labels: List[str] + matrix: List[List[float]] + reversed_polarity: bool = True + + def to_montage_view(self): + from .montageview import MontageView + + mv = MontageView( + list(self.montage_labels), + list(self.rec_labels), + reversed_polarity=self.reversed_polarity, + ) + arr = np.asarray(self.matrix, dtype=float) + expected = (len(self.montage_labels), len(self.rec_labels)) + if arr.shape != expected: + raise ValueError( + f"matrix shape {arr.shape} does not match " + f"(len(montage_labels), len(rec_labels))={expected}" + ) + mv.V.data[:] = arr + mv.name = "from_file" + return mv + + @classmethod + def from_montage_view(cls, mv) -> "MontageDerivation": + return cls( + montage_labels=list(mv.montage_labels), + rec_labels=list(mv.rec_labels), + matrix=np.asarray(mv.V.data).tolist(), + reversed_polarity=getattr(mv, "reversed_polarity", True), + ) + + +@dataclass +class VirtualElectrode: + """A named linear combination of physical electrodes. + + Used wherever an electrode name is referenced inside a + :class:`SymbolicChannel` (in ``diffpair`` or ``sum_coefficients``). + Virtual names take precedence over physical electrodes of the same + name when resolved against ``rec_labels``. + """ + + type: str = "mean" + electrodes: List[str] = field(default_factory=list) + weights: Dict[str, float] = field(default_factory=dict) + + def resolve(self, rec_labels: List[str]) -> np.ndarray: + """Return a length-``len(rec_labels)`` coefficient vector.""" + n = len(rec_labels) + out = np.zeros(n, dtype=float) + idx = {lbl: i for i, lbl in enumerate(rec_labels)} + if self.type == "mean": + if not self.electrodes: + raise ValueError( + "VirtualElectrode type 'mean' requires a non-empty 'electrodes' list" + ) + w = 1.0 / len(self.electrodes) + for e in self.electrodes: + if e not in idx: + raise KeyError( + f"electrode {e!r} required by virtual 'mean' channel " + f"is not present in rec_labels {rec_labels}" + ) + out[idx[e]] += w + elif self.type == "weighted": + for e, w in self.weights.items(): + if e not in idx: + raise KeyError( + f"electrode {e!r} required by virtual 'weighted' channel " + f"is not present in rec_labels {rec_labels}" + ) + out[idx[e]] += float(w) + else: + raise ValueError( + f"unknown VirtualElectrode type {self.type!r}; " + f"expected 'mean' or 'weighted'" + ) + return out + + +@dataclass +class SymbolicChannel: + """One derived channel defined as a symbolic linear combination. + + Exactly one of ``diffpair`` and ``sum_coefficients`` must be set. + + - ``diffpair=[a, b]`` is the bipolar shorthand: +1 on ``a``, −1 on ``b``. + Either name may reference a physical electrode (in ``rec_labels``) or + a :class:`VirtualElectrode`. + - ``sum_coefficients={name: weight, ...}`` is the general form: an + explicit weighted sum over named electrodes / virtuals. + """ + + label: str + diffpair: Optional[List[str]] = None + sum_coefficients: Optional[Dict[str, float]] = None + + def resolve( + self, + rec_labels: List[str], + virtual_channels: Dict[str, VirtualElectrode], + ) -> np.ndarray: + """Return the coefficient vector this channel applies to rec_labels.""" + n = len(rec_labels) + out = np.zeros(n, dtype=float) + idx = {lbl: i for i, lbl in enumerate(rec_labels)} + + def add(name: str, weight: float) -> None: + if name in virtual_channels: + out[:] += weight * virtual_channels[name].resolve(rec_labels) + elif name in idx: + out[idx[name]] += weight + else: + raise KeyError( + f"channel {self.label!r}: name {name!r} is neither a " + f"virtual channel {list(virtual_channels)} nor a rec_label " + f"{rec_labels}" + ) + + diffpair_set = self.diffpair is not None + coeff_set = self.sum_coefficients is not None + if diffpair_set == coeff_set: + raise ValueError( + f"channel {self.label!r}: must set exactly one of 'diffpair' " + f"or 'sum_coefficients', got diffpair={self.diffpair!r}, " + f"sum_coefficients={self.sum_coefficients!r}" + ) + if diffpair_set: + if len(self.diffpair) != 2: + raise ValueError( + f"channel {self.label!r}: diffpair must be [a, b], " + f"got {self.diffpair!r}" + ) + add(self.diffpair[0], 1.0) + add(self.diffpair[1], -1.0) + else: + for name, w in self.sum_coefficients.items(): + add(name, float(w)) + return out + + +@dataclass +class SymbolicDerivation: + """Portable derivation expressed as symbolic per-channel coefficients. + + The matrix is built at apply time from ``rec_labels`` so the same + profile works across recordings with different electrode sets, as long + as the named electrodes are present. + """ + + channels: List[SymbolicChannel] = field(default_factory=list) + virtual_channels: Dict[str, VirtualElectrode] = field(default_factory=dict) + reversed_polarity: bool = True + + @property + def montage_labels(self) -> List[str]: + return [c.label for c in self.channels] + + def to_montage_view(self, rec_labels: List[str]): + """Resolve virtuals and build a :class:`MontageView`.""" + from .montageview import MontageView + + labels = list(self.montage_labels) + rec_labels = list(rec_labels) + m, n = len(labels), len(rec_labels) + matrix = np.zeros((m, n), dtype=float) + for i, channel in enumerate(self.channels): + matrix[i] = channel.resolve(rec_labels, self.virtual_channels) + if self.reversed_polarity: + matrix = -matrix + mv = MontageView(labels, rec_labels, reversed_polarity=self.reversed_polarity) + mv.V.data[:] = matrix + mv.name = "from_symbolic" + return mv + + def to_dict(self) -> dict: + d: dict = { + "type": "symbolic", + "reversed_polarity": self.reversed_polarity, + } + if self.virtual_channels: + d["virtual_channels"] = { + name: _virtual_to_dict(v) for name, v in self.virtual_channels.items() + } + d["channels"] = [_symbolic_channel_to_dict(c) for c in self.channels] + return d + + @classmethod + def from_dict(cls, d: dict) -> "SymbolicDerivation": + virtuals = { + name: _dataclass_from_dict(VirtualElectrode, v) + for name, v in d.get("virtual_channels", {}).items() + } + channels = [ + _dataclass_from_dict(SymbolicChannel, c) for c in d.get("channels", []) + ] + return cls( + channels=channels, + virtual_channels=virtuals, + reversed_polarity=d.get("reversed_polarity", True), + ) + + +def _virtual_to_dict(v: VirtualElectrode) -> dict: + """Serialize a VirtualElectrode, omitting the unused field.""" + if v.type == "mean": + return {"type": "mean", "electrodes": list(v.electrodes)} + if v.type == "weighted": + return {"type": "weighted", "weights": dict(v.weights)} + return dataclasses.asdict(v) + + +def _symbolic_channel_to_dict(c: SymbolicChannel) -> dict: + """Serialize a SymbolicChannel, including only the populated form.""" + out: dict = {"label": c.label} + if c.diffpair is not None: + out["diffpair"] = list(c.diffpair) + if c.sum_coefficients is not None: + out["sum_coefficients"] = dict(c.sum_coefficients) + return out + + +# Registry of built-in MontageView subclasses, keyed by short name. +_BUILTIN_DERIVATIONS: Dict[str, Callable] = {} + + +def register_builtin_derivation(name: str, factory: Callable) -> None: + """Register a ``MontageView`` factory under ``name``. + + The factory is called as ``factory(rec_labels)`` and must return a + ``MontageView`` (or compatible) instance with ``.V``, ``.montage_labels``, + and ``.rec_labels``. + """ + _BUILTIN_DERIVATIONS[name] = factory + + +def _register_defaults() -> None: + if _BUILTIN_DERIVATIONS: + return + from .montageview import ( + CircumferentialMontageView, + CommonAvgRefMontageView, + DoubleBananaMontageView, + LaplacianMontageView, + NeonatalMontageView, + TCPMontageView, + TraceMontageView, + TrueSphenoidalMontageView, + ) + + register_builtin_derivation("double_banana", DoubleBananaMontageView) + register_builtin_derivation("tcp", TCPMontageView) + register_builtin_derivation("laplacian", LaplacianMontageView) + register_builtin_derivation("circle", CircumferentialMontageView) + register_builtin_derivation("neonatal", NeonatalMontageView) + register_builtin_derivation("common_avg", CommonAvgRefMontageView) + register_builtin_derivation("true_sphenoidal", TrueSphenoidalMontageView) + # 'trace' is special: identity matrix over rec_labels, channels are + # whatever the recording provides. Cannot be expressed as a static + # symbolic profile — it lives only as a derivation_ref. See SCHEMA.md. + register_builtin_derivation("trace", TraceMontageView) + + +def list_builtin_derivations() -> List[str]: + _register_defaults() + return sorted(_BUILTIN_DERIVATIONS.keys()) + + +@dataclass +class MontageDisplay: + """A montage derivation plus display profile (groups, spacing, colors). + + Exactly one of ``derivation_ref`` and ``derivation`` should be set: + ``derivation_ref`` references a built-in by name (compact) and requires + ``rec_labels`` at build time; ``derivation`` carries a self-contained + matrix (portable across installations). + + **Channel order convention**: channels listed earlier in ``groups`` (and + earlier within a group's ``channels`` list) are drawn higher on the + page. A ``gap_after_mm`` on a group or channel inserts visual space + *below* that group/channel, i.e. between it and whatever follows in the + file. Renderers are responsible for translating this top-down order to + whatever internal coordinate convention they use. + """ + + name: str = "" + description: str = "" + derivation_ref: Optional[str] = None + derivation: Optional[Union[MontageDerivation, SymbolicDerivation]] = None + groups: List[ChannelGroup] = field(default_factory=list) + channel_overrides: Dict[str, ChannelStyle] = field(default_factory=dict) + + def _iter_visible(self): + """Yield (group, channel_label, is_last_visible_in_group).""" + for g in self.groups: + visible = [ + c + for c in g.channels + if self.channel_overrides.get(c, ChannelStyle(c)).visible + ] + for i, c in enumerate(visible): + yield g, c, (i == len(visible) - 1) + + def resolve_ordered_labels(self) -> List[str]: + """Flatten visible channels across groups into display order.""" + return [c for _, c, _ in self._iter_visible()] + + def resolve_gaps_mm(self) -> List[float]: + """Per-channel ``gap_after_mm`` in display order. + + Channel overrides win over the group's ``gap_after_mm``; the group's + gap is only applied to the last visible channel of that group. + """ + out: List[float] = [] + for g, c, is_last in self._iter_visible(): + override = self.channel_overrides.get(c) + if override is not None and override.gap_after_mm is not None: + out.append(float(override.gap_after_mm)) + elif is_last: + out.append(float(g.gap_after_mm)) + else: + out.append(0.0) + return out + + def resolve_colors(self) -> List[Optional[str]]: + """Per-channel color in display order (None = use renderer default).""" + out: List[Optional[str]] = [] + for g, c, _ in self._iter_visible(): + override = self.channel_overrides.get(c) + if override is not None and override.color is not None: + out.append(override.color) + else: + out.append(g.color) + return out + + def resolve_gains(self) -> List[float]: + """Per-channel gain multiplier in display order.""" + out: List[float] = [] + for _, c, _ in self._iter_visible(): + override = self.channel_overrides.get(c) + out.append(float(override.gain) if override is not None else 1.0) + return out + + def build_montage_view(self, rec_labels: Optional[List[str]] = None): + """Build a ``MontageView`` from the profile's derivation. + + - ``derivation`` (symbolic): resolves virtuals against + ``rec_labels`` to build the matrix. + - ``derivation`` (matrix): self-contained, ``rec_labels`` is unused. + - ``derivation_ref``: looks up a built-in ``MontageView`` factory; + requires ``rec_labels``. + """ + if self.derivation is not None: + if isinstance(self.derivation, SymbolicDerivation): + if rec_labels is None: + raise ValueError( + "rec_labels is required when MontageDisplay uses a " + "symbolic derivation" + ) + return self.derivation.to_montage_view(rec_labels) + return self.derivation.to_montage_view() + if self.derivation_ref is not None: + if rec_labels is None: + raise ValueError( + "rec_labels is required when MontageDisplay uses derivation_ref" + ) + _register_defaults() + factory = _BUILTIN_DERIVATIONS.get(self.derivation_ref) + if factory is None: + raise KeyError( + f"unknown derivation_ref {self.derivation_ref!r}; " + f"known: {list_builtin_derivations()}" + ) + return factory(list(rec_labels)) + raise ValueError("MontageDisplay has neither derivation nor derivation_ref") + + def to_dict(self) -> dict: + d: dict = { + "name": self.name, + "description": self.description, + "groups": [dataclasses.asdict(g) for g in self.groups], + "channel_overrides": { + k: dataclasses.asdict(v) for k, v in self.channel_overrides.items() + }, + } + if self.derivation_ref is not None: + d["derivation_ref"] = self.derivation_ref + if self.derivation is not None: + if isinstance(self.derivation, SymbolicDerivation): + d["derivation"] = self.derivation.to_dict() + else: + deriv_dict = dataclasses.asdict(self.derivation) + deriv_dict["type"] = "matrix" + d["derivation"] = deriv_dict + return d + + @classmethod + def from_dict(cls, d: dict, validate: bool = True) -> "MontageDisplay": + """Build a :class:`MontageDisplay` from a dict. + + If ``validate`` is True (default) the dict is checked against + :func:`validate_montage_display_dict` first so typos and structural + errors raise with a clear pointer to the bad field. Pass + ``validate=False`` to tolerate unknown keys (forward-compat or + partial dicts in tests). + """ + if validate: + validate_montage_display_dict(d) + groups = [_dataclass_from_dict(ChannelGroup, g) for g in d.get("groups", [])] + overrides = { + k: _dataclass_from_dict(ChannelStyle, v) + for k, v in d.get("channel_overrides", {}).items() + } + deriv = None + deriv_dict = d.get("derivation") + if deriv_dict is not None: + dtype = deriv_dict.get("type", "matrix") + if dtype == "symbolic": + deriv = SymbolicDerivation.from_dict(deriv_dict) + elif dtype == "matrix": + deriv = _dataclass_from_dict(MontageDerivation, deriv_dict) + else: + raise ValueError( + f"unknown derivation type {dtype!r}; expected " + f"'symbolic' or 'matrix'" + ) + return cls( + name=d.get("name", ""), + description=d.get("description", ""), + derivation_ref=d.get("derivation_ref"), + derivation=deriv, + groups=groups, + channel_overrides=overrides, + ) + + def save(self, path) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(self.to_dict(), f, indent=2) + + @classmethod + def load(cls, path, validate: bool = True) -> "MontageDisplay": + with open(path, "r", encoding="utf-8") as f: + return cls.from_dict(json.load(f), validate=validate) + + +def _dataclass_from_dict(klass, data: dict): + """Construct a dataclass instance from a dict, ignoring unknown keys. + + Tolerates older or partial JSON without raising on extra fields. + """ + valid = {f.name for f in dataclasses.fields(klass)} + return klass(**{k: v for k, v in data.items() if k in valid}) + + +# ----- JSON Schema validation ----------------------------------------------- + +_SCHEMA_PATH = ( + Path(__file__).resolve().parent / "displays" / "montage_display.schema.json" +) +_SCHEMA_CACHE: Optional[dict] = None +_VALIDATOR_CACHE = None + + +def _load_schema() -> dict: + global _SCHEMA_CACHE + if _SCHEMA_CACHE is None: + with open(_SCHEMA_PATH, "r", encoding="utf-8") as f: + _SCHEMA_CACHE = json.load(f) + return _SCHEMA_CACHE + + +def _get_validator(): + global _VALIDATOR_CACHE + if _VALIDATOR_CACHE is None: + import jsonschema + + schema = _load_schema() + cls = jsonschema.validators.validator_for(schema) + cls.check_schema(schema) + _VALIDATOR_CACHE = cls(schema) + return _VALIDATOR_CACHE + + +def _format_jsonpath(path) -> str: + """Render a jsonschema absolute_path as a readable dotted/indexed path. + + Empty path renders as ``""``. + """ + parts: List[str] = [] + for p in path: + if isinstance(p, int): + parts.append(f"[{p}]") + elif parts: + parts.append(f".{p}") + else: + parts.append(str(p)) + return "".join(parts) or "" + + +def _all_leaves(error): + """Flatten an error tree to its terminal sub-errors (no further context).""" + if not error.context: + return [error] + out = [] + for sub in error.context: + out.extend(_all_leaves(sub)) + return out + + +# Validator keywords that aren't useful at the leaf level — these are +# composite gates that re-wrap children. Skip them when picking the best +# leaf so the message points at a concrete constraint. +_COMPOSITE_KEYWORDS = {"oneOf", "anyOf", "allOf"} + + +def _leaf_score(error): + """Rank a leaf for actionability. + + Higher is better. Prioritizes deeper paths, then prefers concrete + validator keywords (``additionalProperties``, ``required``, ``const``, + ``enum``, ``type``, ``not``) over composite gates. + """ + depth = len(list(error.absolute_path)) + keyword = error.validator or "" + composite_penalty = -1 if keyword in _COMPOSITE_KEYWORDS else 0 + return (depth, composite_penalty) + + +def _customize_message(error) -> str: + """Rewrite a few common jsonschema messages into clinician-friendly form.""" + keyword = error.validator or "" + if keyword == "not": + # "not" failures on sibling-required clauses — i.e. mutually + # exclusive fields. Identify the field by inspecting the schema. + schema = error.schema or {} + not_clause = schema.get("not", {}) if isinstance(schema, dict) else {} + required = ( + not_clause.get("required", []) if isinstance(not_clause, dict) else [] + ) + if required: + forbidden = ", ".join(required) + return f"{forbidden} must not be set here" + if keyword == "required": + missing = list(getattr(error, "validator_value", []) or []) + if missing: + return f"missing required field(s): {', '.join(missing)}" + return error.message + + +def validate_montage_display_dict(d: dict) -> None: + """Validate ``d`` against the bundled JSON Schema. + + On failure, raises :class:`jsonschema.ValidationError` whose message + is prefixed with the dotted/indexed path to the offending field + (e.g. ``"at derivation.channels[0]: 'pair' was unexpected"``). For + ``oneOf`` / ``anyOf`` failures the message points to the deepest + concrete sub-error rather than the parent "not valid under any of + the given schemas". + """ + validator = _get_validator() + errors = list(validator.iter_errors(d)) + if not errors: + return + leaves: list = [] + for e in errors: + leaves.extend(_all_leaves(e)) + if not leaves: + # Edge case: shouldn't happen since iter_errors returned something. + leaves = errors + leaves.sort(key=_leaf_score, reverse=True) + best = leaves[0] + best.message = ( + f"at {_format_jsonpath(best.absolute_path)}: {_customize_message(best)}" + ) + raise best + + +# A clinically reasonable default for the standard 18-channel double banana, +# with left-hemisphere chains in blue and right-hemisphere chains in red, plus +# small visual breaks between the four temporal/parasagittal chains and the +# midline pair. +DEFAULT_LEFT_COLOR = "#1f4e79" +DEFAULT_RIGHT_COLOR = "#a8323e" +DEFAULT_MIDLINE_COLOR = "#000000" + + +def default_double_banana_display() -> MontageDisplay: + return MontageDisplay( + name="double_banana_default", + description=( + "Standard 18-channel double banana, chains color-coded by " + "hemisphere with extra spacing between chains." + ), + derivation_ref="double_banana", + groups=[ + ChannelGroup( + "left_temporal", + ["Fp1-F7", "F7-T3", "T3-T5", "T5-O1"], + color=DEFAULT_LEFT_COLOR, + gap_after_mm=3.0, + ), + ChannelGroup( + "right_temporal", + ["Fp2-F8", "F8-T4", "T4-T6", "T6-O2"], + color=DEFAULT_RIGHT_COLOR, + gap_after_mm=3.0, + ), + ChannelGroup( + "left_parasagittal", + ["Fp1-F3", "F3-C3", "C3-P3", "P3-O1"], + color=DEFAULT_LEFT_COLOR, + gap_after_mm=3.0, + ), + ChannelGroup( + "right_parasagittal", + ["Fp2-F4", "F4-C4", "C4-P4", "P4-O2"], + color=DEFAULT_RIGHT_COLOR, + gap_after_mm=3.0, + ), + ChannelGroup( + "midline", + ["Fz-Cz", "Cz-Pz"], + color=DEFAULT_MIDLINE_COLOR, + ), + ], + ) diff --git a/eegvis/viewer/editor_components.py b/eegvis/viewer/editor_components.py new file mode 100644 index 0000000..faadaad --- /dev/null +++ b/eegvis/viewer/editor_components.py @@ -0,0 +1,376 @@ +"""ztml components for the MontageDisplay editor page. + +The editor presents a flat table of channels and separators (one row each) +modeled on classic clinical "Pattern Editor" dialogs. Inline inputs are +bound to the server's :class:`EditorSession` via datastar; each edit +triggers an SSE patch that re-renders the table and the preview SVG. +""" + +from __future__ import annotations + +from typing import List + +from ztml import ( + Body, + Button, + Div, + Fragment, + H2, + Head, + Html, + Input, + Label, + Meta, + Option, + Raw, + RawCss, + Script, + Select, + Span, + Style, + Title, +) + +from .editor_session import ( + DEFAULT_COLOR_PALETTE, + EditorRow, + EditorSession, +) + +DATASTAR_CDN = "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-RC.8/bundles/datastar.js" + + +def editor_page( + session: EditorSession, + profile_options: dict, + preview_svg: str, +): + """Top-level editor HTML document.""" + return Fragment( + Raw(""), + Html( + Head( + Meta().charset("utf-8"), + Meta().name("viewport").content("width=device-width, initial-scale=1"), + Title(f"Montage Editor — {session.name}"), + Script().src(DATASTAR_CDN).type("module"), + _editor_styles(), + ), + Body( + Div( + Div( + _editor_header(session, profile_options), + _editor_table(session), + ).cls("editor-main"), + _editor_sidebar(session, profile_options), + ).cls("editor-root"), + Div( + H2("Preview"), + _editor_preview(preview_svg), + ).cls("editor-preview"), + ), + ), + ) + + +def _editor_styles(): + return Style( + RawCss(""" + * { box-sizing: border-box; margin: 0; padding: 0; } + body { font-family: system-ui, -apple-system, sans-serif; background: #f3f3f3; color: #222; } + .editor-root { + display: grid; + grid-template-columns: minmax(0, 1fr) 240px; + gap: 12px; + padding: 12px; + max-width: 1400px; + margin: 0 auto; + } + .editor-main { background: white; border: 1px solid #ccc; border-radius: 4px; padding: 12px; } + .editor-header { + display: grid; + grid-template-columns: 120px 1fr 120px 1fr; + align-items: center; + gap: 8px; + margin-bottom: 12px; + } + .editor-header label { font-size: 12px; color: #555; } + .editor-header select, .editor-header input[type=text] { + padding: 4px 6px; border: 1px solid #ccc; border-radius: 3px; + font-size: 13px; width: 100%; + } + .editor-table { + width: 100%; border-collapse: collapse; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + } + .editor-table th { + background: #ececec; padding: 4px 6px; + border: 1px solid #bbb; text-align: center; font-weight: 600; + } + .editor-table td { border: 1px solid #ddd; padding: 0; } + .editor-table tr.selected td { background: #e0ecff; } + .editor-table tr.separator td { background: #2664c5; color: white; } + .editor-table tr.separator td.ch-cell { background: #fafafa; color: #2664c5; } + .editor-table .ch-cell { + text-align: center; padding: 4px 8px; font-weight: 600; cursor: pointer; + } + .editor-table input[type=text], + .editor-table input[type=color] { + width: 100%; border: none; padding: 4px 6px; + font: inherit; background: transparent; + } + .editor-table input[type=text]:focus { background: #fff8c5; outline: 1px solid #4285f4; } + .editor-table input.num-cell { width: 100%; text-align: right; max-width: 60px; } + .editor-table .disp-cell { text-align: center; cursor: pointer; user-select: none; } + .editor-table .color-cell { width: 60px; padding: 2px; } + .editor-table input[type=color] { height: 22px; padding: 0; cursor: pointer; } + + .editor-sidebar { + background: white; border: 1px solid #ccc; border-radius: 4px; + padding: 12px; align-self: start; display: grid; gap: 12px; + position: sticky; top: 12px; + } + .editor-sidebar .group { display: grid; gap: 6px; padding: 8px; border: 1px solid #ddd; border-radius: 3px; } + .editor-sidebar .group-title { font-size: 11px; text-transform: uppercase; color: #777; letter-spacing: 0.04em; } + .editor-sidebar button { + padding: 6px 10px; border: 1px solid #bbb; background: #f7f7f7; + border-radius: 3px; cursor: pointer; font-size: 13px; + } + .editor-sidebar button:hover { background: #ececec; } + .editor-sidebar button.primary { background: #2664c5; color: white; border-color: #1d4fa0; } + .editor-sidebar button.primary:hover { background: #1d4fa0; } + .editor-sidebar .row-button { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; } + + .editor-preview { + max-width: 1400px; margin: 0 auto 16px; padding: 0 12px; + } + .editor-preview h2 { font-size: 13px; color: #555; margin: 4px 0; } + .editor-preview-svg { + background: white; border: 1px solid #ccc; border-radius: 4px; + padding: 8px; overflow: hidden; + } + .editor-preview-svg svg { width: 100%; height: auto; } + """) + ) + + +def _editor_header(session: EditorSession, profile_options: dict): + return ( + Div( + Label("Pattern"), + _pattern_select(session, profile_options), + Label("Name"), + Input() + .type("text") + .value(session.name) + .id("editor-name") + .attr( + "data-on:change", + f"@post('/editor/api/rename?session_id={session.session_id}&name=' + encodeURIComponent(evt.target.value))", + ), + ) + .id("editor-header") + .cls("editor-header") + ) + + +def _pattern_select(session: EditorSession, profile_options: dict): + options = [] + system = profile_options.get("system", []) + user = profile_options.get("user", []) + options.append(Option("— new —").value("")) + if user: + options.append(Option("— user —").attr("disabled", "disabled")) + for n in user: + options.append(Option(n).value(f"user:{n}")) + if system: + options.append(Option("— system —").attr("disabled", "disabled")) + for n in system: + options.append(Option(n).value(f"system:{n}")) + sel = Select(*options) + sel = sel.attr( + "data-on:change", + f"@post('/editor/api/load?session_id={session.session_id}&scoped=' + encodeURIComponent(evt.target.value))", + ) + return sel + + +def _editor_table(session: EditorSession): + rows_html: List = [] + rows_html.append( + Raw( + """ + + + CH#G1G2 + Sens + LF + HF + CAL + Width + Disp + Color + + + + """ + ) + ) + rows_html.append(_render_tbody(session)) + return Raw(f'{_concat(rows_html)}
') + + +def _render_tbody(session: EditorSession) -> Raw: + """Just the — what the SSE patch targets when state changes.""" + parts = [''] + for i, row in enumerate(session.rows): + parts.append(_render_row(session.session_id, i, row, i == session.selected_row)) + parts.append("") + return Raw("".join(parts)) + + +def _render_row(session_id: str, idx: int, row: EditorRow, selected: bool) -> str: + """Render one row of the editor table as raw HTML (faster than ztml chain).""" + klass = [] + if selected: + klass.append("selected") + if row.kind == "separator": + klass.append("separator") + klass_attr = f' class="{" ".join(klass)}"' if klass else "" + + set_cell_url = f"/editor/api/set_cell?session_id={session_id}&row={idx}" + select_url = f"/editor/api/select?session_id={session_id}&row={idx}" + + ch_cell = ( + f'CH{idx + 1}' + ) + + if row.kind == "separator": + body = ( + '— separator —' + '' + ) + return f"{ch_cell}{body}" + + def _text(field: str, value: str) -> str: + return ( + f'" + ) + + def _num(field: str, value) -> str: + """Numeric input. Empty string means 'inherit / no override'.""" + display = "" if value is None else _format_num(value) + return ( + f'" + ) + + color_val = row.color or "#000000" + color_cell = ( + f'" + ) + + disp_label = "On" if row.visible else "Off" + next_val = "false" if row.visible else "true" + disp_cell = ( + f'" + f"{disp_label}" + ) + + return ( + f"" + f"{ch_cell}" + f"{_text('g1', row.g1)}" + f"{_text('g2', row.g2)}" + f"{_num('sensitivity', row.sensitivity)}" + f"{_num('lf', row.lf)}" + f"{_num('hf', row.hf)}" + f"{_num('cal', row.cal)}" + f"{_num('width', row.width)}" + f"{disp_cell}" + f"{color_cell}" + f"" + f"" + ) + + +def _format_num(value) -> str: + """Render a float for the editor: integers as ints, floats trimmed.""" + if isinstance(value, int) or (isinstance(value, float) and value.is_integer()): + return str(int(value)) + return f"{float(value):g}" + + +def _editor_sidebar(session: EditorSession, profile_options: dict): + sid = session.session_id + return Div( + Div( + Span("Channel").cls("group-title"), + Div( + Button("Insert").attr( + "data-on:click", + f"@post('/editor/api/insert_channel?session_id={sid}')", + ), + Button("Delete").attr( + "data-on:click", f"@post('/editor/api/delete_row?session_id={sid}')" + ), + ).cls("row-button"), + ).cls("group"), + Div( + Span("Separator").cls("group-title"), + Div( + Button("Insert").attr( + "data-on:click", + f"@post('/editor/api/insert_separator?session_id={sid}')", + ), + Button("Delete").attr( + "data-on:click", f"@post('/editor/api/delete_row?session_id={sid}')" + ), + ).cls("row-button"), + ).cls("group"), + Div( + Span("File").cls("group-title"), + Button("Save to user dir") + .cls("primary") + .attr("data-on:click", f"@post('/editor/api/save?session_id={sid}')"), + ).cls("group"), + ).cls("editor-sidebar") + + +def _editor_preview(svg: str): + return Div(Raw(svg)).cls("editor-preview-svg").id("editor-preview") + + +# ----- helpers ----- + + +def _concat(items) -> str: + return "".join(render_fragment(i) for i in items) + + +def render_fragment(item) -> str: + """Stringify either a ztml element, a Raw, or a plain str.""" + if isinstance(item, str): + return item + if hasattr(item, "__html__"): + return item.__html__() + return str(item) + + +def _escape(s: str) -> str: + return ( + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def render_tbody_patch(session: EditorSession) -> str: + """Return the inner HTML of the editor tbody for SSE patches.""" + return render_fragment(_render_tbody(session)) diff --git a/eegvis/viewer/editor_session.py b/eegvis/viewer/editor_session.py new file mode 100644 index 0000000..3982e68 --- /dev/null +++ b/eegvis/viewer/editor_session.py @@ -0,0 +1,272 @@ +"""Editor session state + flat-row representation of a MontageDisplay. + +The montage-editor UI presents the profile as a flat list of rows, the +same way classic clinical "Pattern Editor" dialogs (NK/Stellate/Persyst) +do. Each row is either a *channel* (G1-G2 bipolar pair) or a *separator* +(visual gap between groups). This module converts between that flat form +and the structured :class:`MontageDisplay` we serialize to JSON. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from ..montage_display import ( + ChannelGroup, + ChannelStyle, + MontageDisplay, + SymbolicChannel, + SymbolicDerivation, +) + +# How much vertical mm a separator row contributes between groups when we +# fold flat rows back into MontageDisplay groups. +DEFAULT_SEPARATOR_MM = 5.0 + +# Default colors offered in the editor color picker (clinical convention: +# blue=left, red=right, black=midline). +DEFAULT_COLOR_PALETTE = [ + "#000000", # black / midline + "#1f4e79", # blue / left + "#a8323e", # red / right + "#3a7ab8", # light blue / left accent + "#d9686f", # light red / right accent + "#4a8c2e", # green + "#7b3aa2", # purple +] + + +@dataclass +class EditorRow: + """One row in the flat editor view. + + Channel row: ``kind == "channel"``, ``g1``/``g2`` are electrode labels. + Separator row: ``kind == "separator"``, electrode fields are unused. + + The clinical-style per-channel attributes (``sensitivity``, ``lf``, + ``hf``, ``cal``, ``width``) mirror :class:`ChannelStyle` fields and + round-trip through ``channel_overrides`` on save/load. + """ + + kind: str = "channel" # "channel" | "separator" + g1: str = "" + g2: str = "" + color: Optional[str] = None + visible: bool = True + sensitivity: Optional[float] = None + lf: Optional[float] = None + hf: Optional[float] = None + cal: Optional[float] = None + width: Optional[float] = None + + +@dataclass +class EditorSession: + """Working state of one editor instance, keyed by ``session_id``.""" + + session_id: str = field(default_factory=lambda: str(uuid.uuid4())) + name: str = "untitled" + description: str = "" + rows: List[EditorRow] = field(default_factory=list) + selected_row: int = 0 + save_scope: str = "user" # "user" | "system" + + # ----- conversion to/from MontageDisplay ----- + + def to_display(self) -> MontageDisplay: + """Fold the flat rows back into a :class:`MontageDisplay`. + + Contiguous non-separator rows become one :class:`ChannelGroup`; + the group's ``gap_after_mm`` is set when a separator row follows. + Per-row color overrides are emitted as ``ChannelStyle`` entries. + Symbolic ``diffpair`` is used for every channel. + """ + channels: List[SymbolicChannel] = [] + groups: List[ChannelGroup] = [] + overrides: Dict[str, ChannelStyle] = {} + + current: List[str] = [] + group_idx = 1 + + def flush(gap_mm: float) -> None: + nonlocal group_idx + if not current: + return + groups.append( + ChannelGroup( + name=f"group{group_idx}", + channels=list(current), + gap_after_mm=gap_mm, + ) + ) + current.clear() + group_idx += 1 + + for row in self.rows: + if row.kind == "separator": + flush(DEFAULT_SEPARATOR_MM) + continue + label = _channel_label(row.g1, row.g2) + if label is None: + continue # skip half-blank channel rows + channels.append(SymbolicChannel(label=label, diffpair=[row.g1, row.g2])) + current.append(label) + style: Optional[ChannelStyle] = None + if row.color: + style = overrides.setdefault(label, ChannelStyle(label=label)) + style.color = row.color + if not row.visible: + style = overrides.setdefault(label, ChannelStyle(label=label)) + style.visible = False + for attr in ("sensitivity", "lf", "hf", "cal", "width"): + v = getattr(row, attr) + if v is not None: + style = overrides.setdefault(label, ChannelStyle(label=label)) + setattr(style, attr, v) + flush(0.0) + + return MontageDisplay( + name=self.name, + description=self.description, + derivation=SymbolicDerivation(channels=channels), + groups=groups, + channel_overrides=overrides, + ) + + @classmethod + def from_display(cls, display: MontageDisplay) -> "EditorSession": + """Unfold a :class:`MontageDisplay` into editor rows. + + Symbolic ``diffpair`` channels become channel rows. Channels using + ``sum_coefficients`` are dropped from the flat view but kept on + the session's underlying display so they're not lost when we save + (this is a v1 limitation — the editor doesn't yet expose weighted + sums in the UI). + """ + rows: List[EditorRow] = [] + diffpairs: Dict[str, List[str]] = {} + if display.derivation is not None and isinstance( + display.derivation, SymbolicDerivation + ): + for ch in display.derivation.channels: + if ch.diffpair is not None: + diffpairs[ch.label] = list(ch.diffpair) + + for gi, group in enumerate(display.groups): + for label in group.channels: + pair = diffpairs.get(label) + if pair is None or len(pair) != 2: + # Channel uses sum_coefficients (or comes from a non- + # symbolic derivation). Show as a "computed" placeholder. + rows.append( + EditorRow( + kind="channel", + g1=label, + g2="(computed)", + color=display.channel_overrides.get( + label, ChannelStyle(label=label) + ).color + or group.color, + visible=display.channel_overrides.get( + label, ChannelStyle(label=label) + ).visible, + ) + ) + else: + override = display.channel_overrides.get(label) + rows.append( + EditorRow( + kind="channel", + g1=pair[0], + g2=pair[1], + color=(override.color if override else None) or group.color, + visible=(override.visible if override else True), + sensitivity=override.sensitivity if override else None, + lf=override.lf if override else None, + hf=override.hf if override else None, + cal=override.cal if override else None, + width=override.width if override else None, + ) + ) + # Group's trailing gap becomes a separator row between groups. + if ( + group.gap_after_mm + and group.gap_after_mm > 0 + and gi != len(display.groups) - 1 + ): + rows.append(EditorRow(kind="separator")) + + return cls(name=display.name, description=display.description, rows=rows) + + # ----- mutations ----- + + def insert_channel(self, at: Optional[int] = None) -> int: + """Insert a blank channel row at ``at`` (or after the selected row).""" + idx = self._insert_index(at) + self.rows.insert(idx, EditorRow(kind="channel")) + self.selected_row = idx + return idx + + def insert_separator(self, at: Optional[int] = None) -> int: + idx = self._insert_index(at) + self.rows.insert(idx, EditorRow(kind="separator")) + self.selected_row = idx + return idx + + def delete_row(self, at: Optional[int] = None) -> None: + if not self.rows: + return + idx = self._clamp(at if at is not None else self.selected_row) + del self.rows[idx] + if self.selected_row >= len(self.rows): + self.selected_row = max(0, len(self.rows) - 1) + + def set_cell(self, row: int, field: str, value: str) -> None: + if not 0 <= row < len(self.rows): + return + r = self.rows[row] + if field == "g1": + r.g1 = value + elif field == "g2": + r.g2 = value + elif field == "color": + r.color = value or None + elif field == "visible": + r.visible = value not in ("false", "0", "") + elif field in ("sensitivity", "lf", "hf", "cal", "width"): + setattr(r, field, _parse_optional_float(value)) + else: + raise ValueError(f"unknown field {field!r}") + + # ----- helpers ----- + + def _insert_index(self, at: Optional[int]) -> int: + if at is None: + return min(self.selected_row + 1, len(self.rows)) + return self._clamp(at) + + def _clamp(self, idx: int) -> int: + return max(0, min(idx, len(self.rows) - 1)) if self.rows else 0 + + +def _channel_label(g1: str, g2: str) -> Optional[str]: + g1 = (g1 or "").strip() + g2 = (g2 or "").strip() + if not g1 or not g2: + return None + return f"{g1}-{g2}" + + +def _parse_optional_float(value: str) -> Optional[float]: + """Parse a string from the editor into ``float | None``. Empty -> None.""" + if value is None: + return None + s = str(value).strip() + if not s: + return None + try: + return float(s) + except ValueError: + return None