diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8bc7b4e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,237 @@ +# 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), **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 + +```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) +``` + +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 +pytest tests/test_montage_display.py # MontageDisplay / bundled profiles + +# Run via tox (multiple Python/matplotlib versions) +tox +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) + +- **`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. 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`. + +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. + +### Montage Display Profiles (`montage_display.py` + `displays/`) + +A `MontageDisplay` bundles three things that historically lived in separate +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/`) + +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. 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: + +```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. +- **`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. +- 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`. 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/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/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/__init__.py b/eegvis/__init__.py index 577ae9b..8493866 100644 --- a/eegvis/__init__.py +++ b/eegvis/__init__.py @@ -2,8 +2,8 @@ """eegvis is a set of routines to display electroencephalogram data for the purposes of research and exploration of data -two backends are supported: -1. matplotlib +two backends are supported: +1. matplotlib 2. bokeh/panel: for use in the jupyter notebooks and web dashboards Highlights @@ -11,4 +11,5 @@ - routines that support montage derivations in ways that clinical EEG understand - a full "browser" in the notebook/webpage which supports montages derivations, filtering """ + __version__ = "0.3.1" 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/eegbokeh.py b/eegvis/eegbokeh.py index 262cebb..b06a48b 100644 --- a/eegvis/eegbokeh.py +++ b/eegvis/eegbokeh.py @@ -18,7 +18,7 @@ # get layouts, start ith row, column import bokeh.layouts -#%% +# %% import bokeh.plotting from bokeh.models import FuncTickFormatter, Range1d @@ -39,11 +39,11 @@ from . import montageview from . import stackplot_bokeh from .stackplot_bokeh import limit_sample_check -#from bokeh.io import push_notebook +# from bokeh.io import push_notebook import eegml_signal.filters as esfilters -#%% +# %% # """ # notes on setting ranges for a plot @@ -60,7 +60,7 @@ # this is not used yet -#%% [markdown] +# %% [markdown] # ### [Bokeh Callbacks](https://docs.bokeh.org/en/latest/docs/user_guide/interaction/widgets.html) # To use widgets, you must add them to your document and define their callbacks. Widgets can be added directly to the document root or nested inside a layout. There are two ways to use a widget’s functionality: @@ -87,11 +87,11 @@ # radio_group = RadioGroup(labels=["Option 1", "Option 2", "Option 3"], active=0) # radio_group.on_click(my_radio_handler) # ``` -#%% [markdown] +# %% [markdown] # ### [bokeh.events](https://docs.bokeh.org/en/latest/docs/reference/events.html) -#%% +# %% def ignore_warnings(): @@ -139,7 +139,7 @@ def __init__( @montage is either a string in the standard list or a montageview factory @eeghdf_file - an eeghdf.Eeeghdf instance @page_width_seconds = how big to make the view in seconds - @montage - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function + @montage - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function @start_seconds - center view on this point in time BTW 'trace' is what NK calls its 'as recorded' montage - might be better to call 'raw', 'default' or 'as recorded' @@ -265,7 +265,6 @@ def update_eeghdf_file(self, eeghdf_file, montage="trace", montage_options={}): self.current_montage_instance = None if type(montage) == str: # then we have some work to do if montage in montage_options: - self.current_montage_instance = montage_options[montage]( self.ref_labels ) @@ -276,7 +275,6 @@ def update_eeghdf_file(self, eeghdf_file, montage="trace", montage_options={}): self.current_montage_instance = montage(self.ref_labels) montage_options[self.current_montage_instance.name] = montage else: # use default - self.current_montage_instance = montage_options[0](self.ref_labels) assert self.current_montage_instance @@ -344,7 +342,7 @@ def bokeh_show(self): def update(self): """ - updates the data in the plot + updates the data in the plot so that it will show up can either use bokeh.io.push_notebook() or panel.pane.Bokeh(..) @@ -394,11 +392,10 @@ def update(self): # self.data_source.data['xs'] = xs # self.data_source.data['ys'] = ys - #self.push_notebook() + # self.push_notebook() # do pane.Bokeh::param.trigger('object') on pane holding EEG waveform plot # in notebook updates without a trigger - def stackplot_t( self, tarray, @@ -439,9 +436,7 @@ def stackplot_t( ticklocs = [] if not "plot_width" in kwargs: - kwargs[ - "plot_width" - ] = ( + kwargs["plot_width"] = ( self.ui_plot_width ) # 950 # a default width that is wider but can just fit in jupyter, not sure if plot_width is preferred if not "plot_height" in kwargs: @@ -712,7 +707,7 @@ def show_montage_centered( @ylabels a list of labels for each row ("channel") in marray @yscale with increase (mutiply) the signals in each row by this amount - @montage instance + @montage instance """ @@ -746,9 +741,7 @@ def show_montage_centered( ticklocs = [] if not "plot_width" in kwargs: - kwargs[ - "plot_width" - ] = ( + kwargs["plot_width"] = ( self.ui_plot_width ) # 950 # a default width that is wider but can just fit in jupyter, not sure if plot_width is preferred if not "plot_height" in kwargs: @@ -759,7 +752,7 @@ def show_montage_centered( fig = bokeh.plotting.figure( title=self.title, # tools="pan,box_zoom,reset,previewsave,lasso_select,ywheel_zoom", - #tools="pan,box_zoom,reset,lasso_select,ywheel_zoom", + # tools="pan,box_zoom,reset,lasso_select,ywheel_zoom", tools="crosshair", **kwargs, ) # subclass of Plot that simplifies plot creation @@ -846,7 +839,6 @@ def show_montage_centered( ) return self.fig - def register_top_bar_ui(self): # mlayout = ipywidgets.Layout() @@ -986,9 +978,9 @@ def on_dropdown_change(attr, oldvalue, newvalue, parent=self): # f"on_dropdown_change: {repr(attr)}, {repr(oldvalue)}, {repr(newvalue)}, {parent}" # ) - parent.update_montage(newvalue) + parent.update_montage(newvalue) parent.update_plot_after_montage_change() - parent.update() + parent.update() self.ui_montage_dropdown.on_change("value", on_dropdown_change) @@ -1036,11 +1028,11 @@ def hf_dropdown_on_change(attr, oldvalue, newvalue, parent=self): self.ui_notch_option = CheckboxGroup( labels=["60Hz Notch"] - #, "50Hz Notch"], max_width=100, # disabled=False + # , "50Hz Notch"], max_width=100, # disabled=False ) def notch_change(newvalue, parent=self): - #print(f"on_dropdown_change: {repr(newvalue)}, {parent}") + # print(f"on_dropdown_change: {repr(newvalue)}, {parent}") if newvalue == [0]: self.current_notch_filter = self._notch_filter elif newvalue == []: @@ -1067,7 +1059,7 @@ def ui_gain_on_change(attr, oldvalue, newvalue, parent=self): # print( # f"ui_gain_on_change: {repr(oldvalue)},\n {repr(newvalue)}, {repr(type(newvalue))},{parent}" # ) - + self.yscale = float(newvalue) self.update() @@ -1101,7 +1093,7 @@ def register_bottom_bar_ui(self): # could put goto input here def go_forward(b, parent=self): - #print(b, parent) + # print(b, parent) self.loc_sec = self._limit_time_check(self.loc_sec + 10) self.update() @@ -1125,14 +1117,17 @@ def go_backward1(b, parent=self): self.ui_buttonback1.on_click(go_backward1) - #self.ui_current_location = FloatInput... # keep in sync with jslink? + # self.ui_current_location = FloatInput... # keep in sync with jslink? def go_to_handler(attr, oldvalue, newvalue, parent=self): # print("change:", change) self.loc_sec = self._limit_time_check(float(newvalue)) self.update() self.ui_bottom_bar_layout = bokeh.layouts.row( - self.ui_buttonback, self.ui_buttonf, self.ui_buttonback1, self.ui_buttonf1, + self.ui_buttonback, + self.ui_buttonf, + self.ui_buttonback1, + self.ui_buttonf1, ) return self.ui_bottom_bar_layout # print('displayed buttons') @@ -1202,7 +1197,7 @@ def __init__( @montage is either a string in the standard list or a montageview factory @eeghdf_file - an eeghdf.Eeeghdf instance @page_width_seconds = how big to make the view in seconds - @montage - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function + @montage - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function @start_seconds - center view on this point in time BTW 'trace' is what NK calls its 'as recorded' montage - might be better to call 'raw' @@ -1238,7 +1233,6 @@ def __init__( # defines self.current_montage_instance if type(montage) == str: # then we have some work to do if montage in montage_options: - self.current_montage_instance = montage_options[montage]( self.ref_labels ) @@ -1249,7 +1243,6 @@ def __init__( self.current_montage_instance = montage(self.ref_labels) montage_options[self.current_montage_instance.name] = montage else: # use default - self.current_montage_instance = montage_options[0](self.ref_labels) assert self.current_montage_instance diff --git a/eegvis/eegpanel.py b/eegvis/eegpanel.py index 45699b3..61aa4a5 100644 --- a/eegvis/eegpanel.py +++ b/eegvis/eegpanel.py @@ -18,7 +18,7 @@ # get layouts, start ith row, column import bokeh.layouts -#%% +# %% import bokeh.plotting from bokeh.models import FuncTickFormatter, Range1d @@ -49,7 +49,7 @@ import pdb import signalslot -#%% +# %% # """ # notes on setting ranges for a plot @@ -66,7 +66,7 @@ # this is not used yet -#%% [markdown] +# %% [markdown] # ### [Bokeh Callbacks](https://docs.bokeh.org/en/latest/docs/user_guide/interaction/widgets.html) # To use widgets, you must add them to your document and define their callbacks. Widgets can be added directly to the document root or nested inside a layout. There are two ways to use a widget’s functionality: @@ -93,11 +93,11 @@ # radio_group = RadioGroup(labels=["Option 1", "Option 2", "Option 3"], active=0) # radio_group.on_click(my_radio_handler) # ``` -#%% [markdown] +# %% [markdown] # ### [bokeh.events](https://docs.bokeh.org/en/latest/docs/reference/events.html) -#%% +# %% def ignore_warnings(): @@ -139,7 +139,7 @@ def __init__( start_seconds=-1, montage="neonatal", montage_options={}, - tuh = True, + tuh=True, yscale=1.0, plot_width=950, plot_height=600, @@ -149,7 +149,7 @@ def __init__( @montage is either a string in the standard list or a montageview factory @eeghdf_files - a list of eeghdf.Eeeghdf instances @page_width_seconds = how big to make the view in seconds - @montage - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function + @montage - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function @start_seconds - center view on this point in time @tuh - bool indicating if signal is coming from tuh @@ -260,7 +260,7 @@ def update_eeghdf_file(self, eeghdf_file, montage="trace", montage_options={}): # TODO: this HACK is model specific, we cut out last (signal_length % 60) secs # we do this b/c model does not output probs for clips < 60sec - #pdb.set_trace() + # pdb.set_trace() signal_len = self.eeghdf_file.phys_signals.shape[1] / self.fs remaining_samples = int(self.fs * (signal_len % 60)) if remaining_samples > 0: @@ -285,8 +285,8 @@ def update_eeghdf_file(self, eeghdf_file, montage="trace", montage_options={}): # reference labels are used for montages, since this is an eeghdf file, it can provide these - #TODO: stnaford uses shortcut labels! - self.ref_labels = eeghdf_file.electrode_labels #eeghdf_file.shortcut_elabels + # TODO: stnaford uses shortcut labels! + self.ref_labels = eeghdf_file.electrode_labels # eeghdf_file.shortcut_elabels if not montage_options: # then use builtins and/or ones in the file @@ -303,11 +303,11 @@ def update_eeghdf_file(self, eeghdf_file, montage="trace", montage_options={}): self.current_montage_instance = None if type(montage) == str: # then we have some work to do if montage in montage_options: - #try: + # try: self.current_montage_instance = montage_options[montage]( self.ref_labels ) - #except: + # except: # self.data_source.data.update(dict(xs=[0], ys=[0])) # self.current_montage_instance = montage_options[0](self.ref_labels) else: @@ -317,7 +317,6 @@ def update_eeghdf_file(self, eeghdf_file, montage="trace", montage_options={}): self.current_montage_instance = montage(self.ref_labels) montage_options[self.current_montage_instance.name] = montage else: # use default - self.current_montage_instance = montage_options[0](self.ref_labels) assert self.current_montage_instance @@ -477,9 +476,7 @@ def stackplot_t( ticklocs = [] if not "plot_width" in kwargs: - kwargs[ - "plot_width" - ] = ( + kwargs["plot_width"] = ( self.ui_plot_width ) # 950 # a default width that is wider but can just fit in jupyter, not sure if plot_width is preferred if not "plot_height" in kwargs: @@ -608,7 +605,7 @@ def update_plot_after_montage_change(self): ## xlim(*xlm) # xticks(np.linspace(xlm, 10)) - + dmin = data.min() dmax = data.max() dr = (dmax - dmin) * 0.7 # Crowd them a bit. @@ -752,7 +749,7 @@ def show_montage_centered( @ylabels a list of labels for each row ("channel") in marray @yscale with increase (mutiply) the signals in each row by this amount - @montage instance + @montage instance """ @@ -803,9 +800,7 @@ def show_montage_centered( ticklocs = [] if not "plot_width" in kwargs: - kwargs[ - "plot_width" - ] = ( + kwargs["plot_width"] = ( self.ui_plot_width ) # 950 # a default width that is wider but can just fit in jupyter, not sure if plot_width is preferred if not "plot_height" in kwargs: @@ -927,7 +922,7 @@ def on_filename_dropdown_change(attr, oldvalue, newvalue, parent=self): self.current_montage_instance.name, self.montage_options, ) - self.loc_sec = int(self.page_width_secs/2) + self.loc_sec = int(self.page_width_secs / 2) self.update_plot_after_montage_change() self.update() self.filename_signal.emit(filename=newvalue) @@ -1044,7 +1039,7 @@ def ui_gain_on_change(attr, oldvalue, newvalue, parent=self): self.update() def ui_gain_watcher(ev, parent=self): - "guess how to write a call back for a param watch " + "guess how to write a call back for a param watch" # print(repr(ev), repr(ev.new)) # print(f"updating {self.yscale} -> {ev.new}") self.yscale = float(ev.new) @@ -1125,7 +1120,10 @@ def go_to_handler(attr, oldvalue, newvalue, parent=self): self.update() self.ui_bottom_bar_layout = bokeh.layouts.row( - self.ui_buttonback, self.ui_buttonf, self.ui_buttonback1, self.ui_buttonf1, + self.ui_buttonback, + self.ui_buttonf, + self.ui_buttonback1, + self.ui_buttonf1, ) return self.ui_bottom_bar_layout # print('displayed buttons') @@ -1195,7 +1193,7 @@ def __init__( @montage is either a string in the standard list or a montageview factory @eeghdf_file - an eeghdf.Eeeghdf instance @page_width_seconds = how big to make the view in seconds - @montage - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function + @montage - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function @start_seconds - center view on this point in time BTW 'trace' is what NK calls its 'as recorded' montage - might be better to call 'raw' @@ -1234,7 +1232,6 @@ def __init__( # defines self.current_montage_instance if type(montage) == str: # then we have some work to do if montage in montage_options: - self.current_montage_instance = montage_options[montage]( self.ref_labels ) @@ -1245,7 +1242,6 @@ def __init__( self.current_montage_instance = montage(self.ref_labels) montage_options[self.current_montage_instance.name] = montage else: # use default - self.current_montage_instance = montage_options[0](self.ref_labels) assert self.current_montage_instance diff --git a/eegvis/montage_derivations_edf_simplified.py b/eegvis/montage_derivations_edf_simplified.py index 79fd827..c045f1a 100644 --- a/eegvis/montage_derivations_edf_simplified.py +++ b/eegvis/montage_derivations_edf_simplified.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import division, print_function, absolute_import, unicode_literals import pdb + """ this set of montage derivations are useful when the channel names are in the simplified format ['EEG FP1', 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/montageview.py b/eegvis/montageview.py index b1358b0..5da35b2 100644 --- a/eegvis/montageview.py +++ b/eegvis/montageview.py @@ -160,7 +160,7 @@ class MontageView(object): This linear transformation is defined in the xarray matrix V - For example in the bipolar double banana montage the electrodes + For example in the bipolar double banana montage the electrodes Fp1 and F7 are combined into (Fp1 - F7) """ @@ -292,7 +292,7 @@ class DoubleBananaMontageView(MontageView): *** NOTE this uses the clinical convention and reverses the polarity by default so that "up is negative" *** - + """ DB_LABELS = [ @@ -330,7 +330,7 @@ def __init__(self, rec_labels, reversed_polarity=True): class DBrefMontageView(MontageView): """This montage derivation uses the same electrodes as double banana but uses the as recorded reference - so it is very simple + so it is very simple """ DBREF_LABELS = [ @@ -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): """ @@ -625,10 +715,10 @@ class NeonatalMontageView(MontageView): where rec_labels[i] = where channel labels is string such as "Fp1", "T3", "O2", etc in the 10-20 nomenclature - need to have tose which are included in + need to have tose which are included in NeonatalMontageView.NEONATAL_LABELS - - 10-20 montage modified for neonatal head sizes + + 10-20 montage modified for neonatal head sizes This is more or less Montage 1 in Shellhaas (2011) table 3 of https://www.acns.org/pdf/guidelines/Guideline-13.pdf plus it adds the [ 'T3-O1','O1-O2','O2-T4'] chain to visualize the occipital @@ -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 @@ -844,7 +930,7 @@ def __init__(self, rec_labels, reversed_polarity=True): self.CAR_LABELS, rec_labels, reversed_polarity=reversed_polarity ) - self.set_matrix(self.V) + self.set_matrix(self.V) if reversed_polarity: self.V = (-1) * self.V diff --git a/eegvis/mpl_helpers.py b/eegvis/mpl_helpers.py index b55e99b..064829f 100644 --- a/eegvis/mpl_helpers.py +++ b/eegvis/mpl_helpers.py @@ -16,20 +16,21 @@ """ + import matplotlib import matplotlib.pyplot as plt import numpy as np def transformAxesCoord2FigureCoord(coordarr, ax, fig=None): - """ + """ "axes coord" [0,1]x[0,1] -> "display coord" -> "figure coord" useful to find the correct ax extent in figure coordates if @ax is an Axes object If it has a figure attached already, can just use that, otherwise, specify the figure as @fig - This is not efficient for repetitive use because it calcs the inverse each time, + This is not efficient for repetitive use because it calcs the inverse each time, Look at the code to create a more efficent version """ if not fig: diff --git a/eegvis/nb_eegview.py b/eegvis/nb_eegview.py index ae70eb2..ad8fbb9 100644 --- a/eegvis/nb_eegview.py +++ b/eegvis/nb_eegview.py @@ -62,7 +62,7 @@ class MinimalEEGRecord: essential parts: @signals - acts like a numpy ndarray of shape = (number_of_channels, number_of_samples) - @sample_frequency - float in Hz sampling rate of the signal + @sample_frequency - float in Hz sampling rate of the signal optional parts: useful if you have them @@ -120,7 +120,7 @@ def __init__( @montage is either a string in the standard list or a montageview factory @eeghdf_file - an eeghdf.Eeeghdf instance @page_width_seconds = how big to make the view in seconds - @montage - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function + @montage - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function @start_seconds - center view on this point in time BTW 'trace' is what NK calls its 'as recorded' montage - might be better to call 'raw', 'default' or 'as recorded' @@ -246,7 +246,6 @@ def update_eeghdf_file(self, eeghdf_file, montage="trace", montage_options={}): self.current_montage_instance = None if type(montage) == str: # then we have some work to do if montage in montage_options: - self.current_montage_instance = montage_options[montage]( self.ref_labels ) @@ -257,7 +256,6 @@ def update_eeghdf_file(self, eeghdf_file, montage="trace", montage_options={}): self.current_montage_instance = montage(self.ref_labels) montage_options[self.current_montage_instance.name] = montage else: # use default - self.current_montage_instance = montage_options[0](self.ref_labels) assert self.current_montage_instance @@ -382,7 +380,7 @@ def stackplot_t( ylabels=None, yscale=1.0, topdown=True, - **kwargs + **kwargs, ): """ will plot a stack of traces one above the other assuming @@ -414,9 +412,7 @@ def stackplot_t( ticklocs = [] if not "plot_width" in kwargs: - kwargs[ - "plot_width" - ] = ( + kwargs["plot_width"] = ( self.ui_plot_width ) # 950 # a default width that is wider but can just fit in jupyter, not sure if plot_width is preferred if not "plot_height" in kwargs: @@ -428,7 +424,7 @@ def stackplot_t( title=self.title, # tools="pan,box_zoom,reset,previewsave,lasso_select,ywheel_zoom", tools="pan,box_zoom,reset,lasso_select,ywheel_zoom", - **kwargs + **kwargs, ) # subclass of Plot that simplifies plot creation self.fig = fig @@ -590,7 +586,7 @@ def stackplot( ylabels=None, yscale=1.0, topdown=True, - **kwargs + **kwargs, ): """ will plot a stack of traces one above the other assuming @@ -611,7 +607,7 @@ def stackplot( ylabels=ylabels, yscale=yscale, topdown=True, - **kwargs + **kwargs, ) def show_epoch_centered( @@ -669,7 +665,7 @@ def show_montage_centered( yscale=1.0, montage=None, topdown=True, - **kwargs + **kwargs, ): """ plot an eeg segment using current montage, center the plot at @goto_sec @@ -686,7 +682,7 @@ def show_montage_centered( @ylabels a list of labels for each row ("channel") in marray @yscale with increase (mutiply) the signals in each row by this amount - @montage instance + @montage instance """ @@ -737,9 +733,7 @@ def show_montage_centered( ticklocs = [] if not "plot_width" in kwargs: - kwargs[ - "plot_width" - ] = ( + kwargs["plot_width"] = ( self.ui_plot_width ) # 950 # a default width that is wider but can just fit in jupyter, not sure if plot_width is preferred if not "plot_height" in kwargs: @@ -751,7 +745,7 @@ def show_montage_centered( title=self.title, # tools="pan,box_zoom,reset,previewsave,lasso_select,ywheel_zoom", tools="pan,box_zoom,reset,lasso_select,ywheel_zoom", - **kwargs + **kwargs, ) # subclass of Plot that simplifies plot creation self.fig = fig @@ -1069,7 +1063,7 @@ def __init__( montage=None, montage_options=OrderedDict(), start_seconds=-1, - **kwargs + **kwargs, ): # def __init__(self, eeghdf_file, page_width_seconds=10.0, start_seconds=-1, # montage='trace', montage_options={}, **kwargs): @@ -1079,7 +1073,7 @@ def __init__( @montage is either a string in the standard list or a montageview factory @eeghdf_file - an eeghdf.Eeeghdf instance @page_width_seconds = how big to make the view in seconds - @montage - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function + @montage - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function @start_seconds - center view on this point in time BTW 'trace' is what NK calls its 'as recorded' montage - might be better to call 'raw' @@ -1115,7 +1109,6 @@ def __init__( # defines self.current_montage_instance if type(montage) == str: # then we have some work to do if montage in montage_options: - self.current_montage_instance = montage_options[montage]( self.ref_labels ) @@ -1126,7 +1119,6 @@ def __init__( self.current_montage_instance = montage(self.ref_labels) montage_options[self.current_montage_instance.name] = montage else: # use default - self.current_montage_instance = montage_options[0](self.ref_labels) assert self.current_montage_instance diff --git a/eegvis/stackplot_bokeh.py b/eegvis/stackplot_bokeh.py index 8fd24f1..7464fae 100644 --- a/eegvis/stackplot_bokeh.py +++ b/eegvis/stackplot_bokeh.py @@ -11,6 +11,7 @@ don't call into python (just runs in the browser side) """ + from __future__ import division, print_function, absolute_import import numpy as np @@ -27,7 +28,7 @@ # p = bplt.figure() # p.line([1,2,3,4,5], [6,7,2,4,5], line_width=2) -# bokeh.plotting.show(p) # OR +# bokeh.plotting.show(p) # OR # bplt.show(p) # Note from http://bokeh.pydata.org/en/latest/docs/user_guide/styling.html @@ -48,7 +49,7 @@ def stackplot( ylabels=None, yscale=1.0, topdown=False, - **kwargs + **kwargs, ): """ will plot a stack of traces one above the other assuming @@ -69,7 +70,7 @@ def stackplot( ylabels=ylabels, yscale=yscale, topdown=topdown, - **kwargs + **kwargs, ) @@ -80,7 +81,7 @@ def stackplot_t( ylabels=None, yscale=1.0, topdown=False, - **kwargs + **kwargs, ): """ will plot a stack of traces one above the other assuming @@ -98,7 +99,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: @@ -353,7 +354,7 @@ def __init__( fs, showchannels="all", yscale=3.0, - **kwargs + **kwargs, ): """ showchannels (start,end) given a range of channels might extend later to be some sort of slice @@ -446,7 +447,7 @@ def stackplot_t( ylabels=None, yscale=1.0, topdown=True, # true for this one - **kwargs + **kwargs, ): """ will plot a stack of traces one above the other assuming @@ -464,7 +465,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) if start_time: t = t + start_time @@ -478,9 +479,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,lasso_select", **kwargs ) # subclass of Plot that simplifies plot creation @@ -542,7 +543,7 @@ def stackplot( ylabels=None, yscale=1.0, topdown=True, # true for this? - **kwargs + **kwargs, ): """ will plot a stack of traces one above the other assuming @@ -562,7 +563,7 @@ def stackplot( start_time=start_time, ylabels=ylabels, yscale=yscale, - **kwargs + **kwargs, ) def show_epoch_centered( @@ -640,7 +641,7 @@ class IpyEEGPlot: work in jupyter notebook given an hdf @signal array-like object allow: - - scrolling + - scrolling - goto ? filtering - montaging (linear combinations) @@ -662,7 +663,7 @@ def __init__( showchannels="all", # will depend on montage(s) yscale=3.0, montage=None, - **kwargs + **kwargs, ): self.title = "" # init self.signals = signals @@ -753,7 +754,7 @@ def stackplot_t( ylabels=None, yscale=1.0, topdown=True, - **kwargs + **kwargs, ): """ will plot a stack of traces one above the other assuming @@ -785,13 +786,13 @@ 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( title=self.title, tools="pan,box_zoom,reset,lasso_select,ywheel_zoom", - **kwargs + **kwargs, ) # subclass of Plot that simplifies plot creation # xlim(*xlm) @@ -865,7 +866,7 @@ def stackplot( ylabels=None, yscale=1.0, topdown=True, - **kwargs + **kwargs, ): """ will plot a stack of traces one above the other assuming @@ -886,7 +887,7 @@ def stackplot( ylabels=ylabels, yscale=yscale, topdown=True, - **kwargs + **kwargs, ) def show_epoch_centered( @@ -1023,7 +1024,7 @@ def __init__(self, hdf, page_width_seconds, montage=None, **kwargs): Args: hdf (h5py.File): an eeghdf convention hdf5 file handle - page_width_seconds (float): number of seconds to show + page_width_seconds (float): number of seconds to show montage (a montageview.MontageView instance, optional): a montage with the correct mapping of elctrodes to channel numbers """ rec = hdf["record-0"] @@ -1037,12 +1038,12 @@ def __init__(self, hdf, page_width_seconds, montage=None, **kwargs): electrode_labels=self.electrode_labels, fs=rec.attrs["sample_frequency"], montage=montage, - **kwargs + **kwargs, ) self.title = "hdf %s - montage: %s" % ( hdf.filename, self.current_montage.name if self.current_montage else "", - ) # try to switch from self.current_montage.full_name to .name + ) # try to switch from self.current_montage.full_name to .name class IpyHdfEegPlot2: @@ -1061,12 +1062,12 @@ def __init__( montage_class=None, montage_options={}, start_seconds=-1, - **kwargs + **kwargs, ): """ @eeghdf_file - an eeghdf.Eeeghdf instance @page_width_seconds = how big to make the view in seconds - @montage_class - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function + @montage_class - montageview (class factory) OR a string that identifies a default montage (may want to change this to a factory function @start_seconds - center view on this point in time """ self.eeghdf_file = eeghdf_file @@ -1125,13 +1126,13 @@ def __init__( self.ch_stop = self.current_montage.shape[0] def update_title(self): - if 'full_name' in self.current_montage.__dict__: + if "full_name" in self.current_montage.__dict__: mon_name = self.current_montage.full_name - elif 'name' in self.current_montage.__dict__: + elif "name" in self.current_montage.__dict__: mon_name = self.current_montage.name else: mon_name = "" - + self.title = "hdf %s - montage: %s" % ( self.eeghdf_file.hdf.filename, mon_name, @@ -1229,7 +1230,7 @@ def stackplot_t( ylabels=None, yscale=1.0, topdown=True, - **kwargs + **kwargs, ): """ will plot a stack of traces one above the other assuming @@ -1261,15 +1262,15 @@ 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 + ) if not self.fig: print("creating figure") fig = bplt.figure( title=self.title, tools="pan,box_zoom,reset,lasso_select,ywheel_zoom", - **kwargs + **kwargs, ) # subclass of Plot that simplifies plot creation self.fig = fig @@ -1413,7 +1414,7 @@ def stackplot( ylabels=None, yscale=1.0, topdown=True, - **kwargs + **kwargs, ): """ will plot a stack of traces one above the other assuming @@ -1434,7 +1435,7 @@ def stackplot( ylabels=ylabels, yscale=yscale, topdown=True, - **kwargs + **kwargs, ) def show_epoch_centered( diff --git a/eegvis/stackplot_svg.py b/eegvis/stackplot_svg.py new file mode 100644 index 0000000..e9bbc48 --- /dev/null +++ b/eegvis/stackplot_svg.py @@ -0,0 +1,1090 @@ +# -*- 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. +""" + +from dataclasses import dataclass, field +from typing import List, Optional + +import numpy as np +import xml.etree.ElementTree as ET + + +SVG_NS = "http://www.w3.org/2000/svg" + + +@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): + """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] + 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 + 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 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. + + 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. + + 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, gap_after_mm=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, and to + convert gap_after_mm to data units in auto mode) + gap_after_mm: optional per-channel array of extra gap (in mm of plot + space) after each channel. Length num_channels; the last entry is + ignored. In sensitivity mode the gaps consume the budget exactly; + in auto mode the conversion to data units uses the no-gap channel + spacing and is approximate. + + Returns: + ticklocs: array of y-offsets for each channel in data units + dr: nominal spacing between channels in data units + """ + if gap_after_mm is None: + cum_gap_mm = np.zeros(num_channels, dtype=float) + else: + gaps = np.asarray(gap_after_mm, dtype=float) + if gaps.shape != (num_channels,): + raise ValueError( + f"gap_after_mm must have length {num_channels}, got {gaps.shape}" + ) + # cumulative gap before channel i = sum of gaps[0..i-1] + cum_gap_mm = np.concatenate(([0.0], np.cumsum(gaps[:-1]))) + + ch_indices = np.arange(num_channels, dtype=float) + if sensitivity is not None and height is not None: + total_gap_mm = float(cum_gap_mm[-1]) if num_channels > 0 else 0.0 + usable_height = max(height - total_gap_mm, 1e-6) + dr = sensitivity * usable_height / num_channels + ticklocs = ch_indices * dr + cum_gap_mm * sensitivity + dr / 2.0 + else: + dr = (data.max() - data.min()) * 0.7 + if height is not None and height > 0 and num_channels > 0: + # Approximate conversion: in a no-gap layout, num_channels * dr + # data units span plot_height mm. + data_per_mm = (num_channels * dr) / height + else: + data_per_mm = 0.0 + ticklocs = ch_indices * dr + cum_gap_mm * data_per_mm + 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/__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..3659a08 --- /dev/null +++ b/eegvis/viewer/app.py @@ -0,0 +1,541 @@ +"""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 ..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 + +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, + preserve_aspect_ratio="none", + ) + + +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)) + + +# -------------------------------------------------------------------------- +# 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 + 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..c11bc7b --- /dev/null +++ b/eegvis/viewer/components.py @@ -0,0 +1,371 @@ +# %% +"""ztml HTML components for the EEG viewer.""" + +# from datastar_py import attribute_generator as dsattr # import ServerSentEventGenerator as SSE +# # %% +# # little experiment +# #_id = "fakeid" +# str(dsattr.on('click', f"@get('/api/navigate?session_id={_id}&action=page_back')")) +# # %% +# r = dsattr.on('click', f"@get('/api/navigate?session_id={_id}')").debounce(300) +# # %% +# r.throttle(100) +# # %% +# str(r) +# # %% +# dict(r) +# %% +from ztml import ( + Body, + Button, + Div, + Fragment, + H1, + Head, + Html, + Label, + Meta, + Option, + P, + Raw, + RawCss, + Script, + Select, + Span, + Style, + Title, + Input, +) + +# %% +# Div('testdiv').data("on:click","expression").__html__() +# %% +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; } + 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; + 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; + } + .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; + flex: 1 1 auto; min-height: 0; display: flex; + } + .display-area svg { + width: 100%; height: 100%; flex: 1 1 auto; min-height: 0; + } + .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; + flex: 0 0 auto; + } + .jump-bar { + height: 24px; background: #eee; border: 1px solid #ddd; + border-radius: 3px; position: relative; cursor: pointer; + flex: 0 0 auto; + } + .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") + .data("on:keydown", _keydown_handler(session.session_id)), + # .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/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 diff --git a/eegvis/viewer/session.py b/eegvis/viewer/session.py new file mode 100644 index 0000000..845d6c2 --- /dev/null +++ b/eegvis/viewer/session.py @@ -0,0 +1,131 @@ +"""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/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/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/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/plans/svg-eeg-backend.md b/plans/svg-eeg-backend.md new file mode 100644 index 0000000..14e38e6 --- /dev/null +++ b/plans/svg-eeg-backend.md @@ -0,0 +1,110 @@ +# 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. + +## 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 + +- **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. + +## 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. downsample (optional, via scipy.signal.decimate) + 2. montage matrix multiply → derived signals (M channels, T samples) + 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 +``` + +### 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 + + + + + + +``` + +### Key Design Decisions + +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/pyproject.toml b/pyproject.toml index fb1a817..a27255b 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", @@ -26,6 +27,10 @@ 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", + "jsonschema>=4", # MontageDisplay JSON profile validation ] # may want to break this out into different backends @@ -39,3 +44,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/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/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_montage_display.py b/tests/test_montage_display.py new file mode 100644 index 0000000..29230bd --- /dev/null +++ b/tests/test_montage_display.py @@ -0,0 +1,1130 @@ +"""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 " 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]) 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/test_stackplot_svg.py b/tests/test_stackplot_svg.py new file mode 100644 index 0000000..89ef4fe --- /dev/null +++ b/tests/test_stackplot_svg.py @@ -0,0 +1,712 @@ +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 + + +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" + + +# --- 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("class") == "trace" # vector-effect is in CSS class + + +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 + + +# === Theme system tests === + + +def test_theme_default_produces_valid_svg(): + """DEFAULT_THEME should produce valid SVG with correct classes.""" + signals = np.random.randn(4, 500) + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, theme=stackplot_svg.DEFAULT_THEME + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + assert len(root.findall(".//svg:polyline", ns)) == 4 + # Check style element contains .trace class + style = root.find("svg:style", ns) + assert style is not None + assert ".trace" in style.text + + +def test_stratus_theme_applies_styles(): + """STRATUS_THEME should produce SVG with Stratus-style CSS classes.""" + signals = np.random.randn(8, 500) + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, theme=stackplot_svg.STRATUS_THEME + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + style = root.find("svg:style", ns) + # Stratus theme uses Segoe UI font + assert "Segoe UI" in style.text + # Stratus theme has multiple trace colors + assert "#00007f" in svg_str + # Grid lines should have major-grid class + major_grids = root.findall(".//svg:line[@class='major-grid']", ns) + assert len(major_grids) > 0 + + +def test_publication_theme_applies_styles(): + """PUBLICATION_THEME should produce SVG with publication-style CSS.""" + signals = np.random.randn(4, 500) + svg_str = stackplot_svg.stackplot_svg( + signals, sample_frequency=256.0, theme=stackplot_svg.PUBLICATION_THEME + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + style = root.find("svg:style", ns) + assert "DejaVu Sans" in style.text + # Publication theme has thinner traces + assert "stroke-width: 0.4" in style.text + + +def test_alternating_trace_colors(): + """Theme with multiple trace colors should cycle through them.""" + signals = np.random.randn(12, 500) + svg_str = stackplot_svg.stackplot_svg( + signals, + sample_frequency=256.0, + theme=stackplot_svg.STRATUS_THEME, + color_group_size=4, + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + polylines = root.findall(".//svg:polyline", ns) + colors = [pl.get("stroke") for pl in polylines] + # 12 channels, groups of 4: colors should repeat in groups + # Expected pattern (topdown reverses): blue, black, red, blue, black, red, ... + stratus_colors = ["#00007f", "#000000", "#7f0000"] + for i, c in enumerate(colors): + expected = stratus_colors[(i // 4) % 3] + assert c == expected, f"Channel {i}: got {c}, expected {expected}" + + +def test_minor_grid_lines(): + """When minor_grid=True, minor grid lines should appear with class='minor-grid'.""" + signals = np.random.randn(4, 800) + svg_str = stackplot_svg.stackplot_svg( + signals, + sample_frequency=256.0, + seconds=3.0, + grid_interval=1.0, + minor_grid=True, + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + minor_lines = root.findall(".//svg:line[@class='minor-grid']", ns) + major_lines = root.findall(".//svg:line[@class='major-grid']", ns) + # Should have minor grid lines between major ones + assert len(minor_lines) > 0 + assert len(major_lines) > 0 + # Minor lines should be more numerous than major + assert len(minor_lines) >= len(major_lines) * 2 + + +def test_minor_grid_off_by_default(): + """Without minor_grid=True, no minor grid lines should appear.""" + signals = np.random.randn(4, 800) + svg_str = stackplot_svg.stackplot_svg( + signals, + sample_frequency=256.0, + seconds=3.0, + grid_interval=1.0, + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + minor_lines = root.findall(".//svg:line[@class='minor-grid']", ns) + assert len(minor_lines) == 0 + + +def test_scalebar_bg_rect_stratus(): + """STRATUS_THEME scalebar should have a background rect.""" + signals = np.random.randn(4, 500) + svg_str = stackplot_svg.stackplot_svg( + signals, + sample_frequency=256.0, + theme=stackplot_svg.STRATUS_THEME, + show_scalebar=True, + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + scalebar = root.find(".//svg:g[@class='scalebar']", ns) + rects = scalebar.findall("svg:rect", ns) + assert len(rects) == 1 + assert rects[0].get("fill") == "#dfdfdf" + assert rects[0].get("fill-opacity") == "0.5" + + +def test_scalebar_no_bg_default(): + """DEFAULT_THEME scalebar should not have background rect.""" + signals = np.random.randn(4, 500) + svg_str = stackplot_svg.stackplot_svg( + signals, + sample_frequency=256.0, + theme=stackplot_svg.DEFAULT_THEME, + show_scalebar=True, + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + scalebar = root.find(".//svg:g[@class='scalebar']", ns) + rects = scalebar.findall("svg:rect", ns) + assert len(rects) == 0 + + +def test_theme_wire_through_eeg_to_svg(): + """Theme parameter should pass through eeg_to_svg pipeline.""" + fs = 256.0 + n = int(fs * 5) + signals = np.random.randn(4, n) * 50.0 + svg_str = stackplot_svg.eeg_to_svg( + signals, + fs, + ylabels=["Ch1", "Ch2", "Ch3", "Ch4"], + seconds=5.0, + theme=stackplot_svg.STRATUS_THEME, + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + style = root.find("svg:style", ns) + assert "Segoe UI" in style.text + + +def test_explicit_linecolor_overrides_theme(): + """Explicit linecolor parameter should override theme trace colors.""" + signals = np.random.randn(4, 500) + svg_str = stackplot_svg.stackplot_svg( + signals, + sample_frequency=256.0, + theme=stackplot_svg.STRATUS_THEME, + linecolor="red", + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + polylines = root.findall(".//svg:polyline", ns) + for pl in polylines: + assert pl.get("stroke") == "red" + + +def test_explicit_linewidth_overrides_theme(): + """Explicit linewidth parameter should override theme trace width.""" + signals = np.random.randn(4, 500) + svg_str = stackplot_svg.stackplot_svg( + signals, + sample_frequency=256.0, + theme=stackplot_svg.STRATUS_THEME, + linewidth=1.0, + ) + root = ET.fromstring(svg_str) + ns = {"svg": stackplot_svg.SVG_NS} + polylines = root.findall(".//svg:polyline", ns) + for pl in polylines: + assert float(pl.get("stroke-width")) == 1.0 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? diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..af24109 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1552 @@ +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 = "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" +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 = "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" +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 = "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" +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 = "datastar-py" }, + { name = "eegml-signal" }, + { name = "fastapi" }, + { name = "ipywidgets" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "panel" }, + { name = "scipy" }, + { name = "xarray" }, + { name = "ztml" }, +] + +[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 = "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" }, + { name = "panel" }, + { name = "pyedflib", marker = "extra == 'pyedflib'", specifier = "<=0.1.22" }, + { name = "scipy" }, + { name = "xarray" }, + { name = "ztml", specifier = ">=0.2.4" }, +] +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 = "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" +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 = "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" +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 = "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" +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 = "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" +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 = "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" +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 = "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" +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 = "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" +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 = "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" +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 = "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" +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 = "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" +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" }, +] + +[[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" }, +]