Source code for fastplotlib.widgets.nd_widget._index

from __future__ import annotations

from collections import deque
from concurrent.futures import CancelledError
from dataclasses import dataclass
from numbers import Number
import os
from typing import Sequence, Any, Callable, Iterator

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from ._ndwidget import NDWidget
    from ._base import NDGraphic

from ...utils import loop
from ._async import run_sync


[docs] class RangeContinuous: """ A continuous reference range for a single slider dimension. Stores the (start, stop, step) in scientific units (ex: seconds, micrometers, Hz). The imgui slider for this dimension uses these values to determine its minimum and maximum bounds. The step size is used for the "next" and "previous" buttons. Parameters ---------- start : int or float Minimum value of the range, inclusive. stop : int or float Maximum value of the range, exclusive upper bound. step : int or float Step size used for imgui step next/previous buttons Raises ------ IndexError If ``start >= stop``. Examples -------- A time axis sampled at 1 ms resolution over 10 seconds: RangeContinuous(start=0, stop=10_000, step=1) A depth axis in micrometers with 0.5 µm steps: RangeContinuous(start=0.0, stop=500.0, step=0.5) """ def __init__(self, start: int | float, stop: int | float, step: int | float): if start >= stop: raise IndexError(f"start must be less than stop, {start} !< {stop}") self._start = start self._stop = stop self._step = step self._throttle = 0.05 @property def start(self) -> int | float: """get or set the start boundary of the reference range""" return self._start @start.setter def start(self, val: int | float): self._start = val @property def stop(self) -> int | float: """get or set the stop boundary of the reference range""" return self._stop @stop.setter def stop(self, val: int | float): self._stop = val @property def step(self) -> int | float: """get or set the step size of the range, only used for UI elements""" return self._step @property def throttle(self) -> float: """get or set the minimum time in seconds between slider-drag renders""" return self._throttle @throttle.setter def throttle(self, val: float): if val < 0: raise ValueError("throttle value must be >= 0.0") self._throttle = val @property def size(self) -> int | float: """the size of the reference range""" return self.stop - self.start def __getitem__(self, index: int): """return the value at the index w.r.t. the step size""" if index < 0: raise ValueError("negative indexing not supported") val = self.start + (self.step * index) if not self.start <= val < self.stop: raise IndexError( f"index: {index} value: {val} out of bounds: [{self.start}, {self.stop})" ) return val
[docs] class AutoRangeContinuous(RangeContinuous): """ A continuous reference range that was auto-generated for a slider dimension which had no explicit ``RangeContinuous``. """
@dataclass class RangeDiscrete: """ A discrete reference range for a single slider dimension, where the reference-space values are arbitrary objects (ex: gene names, experimental conditions) rather than a numerical range. .. important:: Not implemented yet, this is a placeholder. The imgui slider is only drawn for a :class:`RangeContinuous`. Parameters ---------- options: Sequence[Any] The reference-space values of this dimension, in order. """ # TODO: not implemented yet, placeholder until we have a clear usecase options: Sequence[Any] def __getitem__(self, index: int): if index > len(self.options): raise IndexError return self.options[index] def __len__(self): return len(self.options)
[docs] class ReferenceIndices: def __init__( self, ref_ranges: dict[ str, tuple[Number, Number, Number] | tuple[Any] | RangeContinuous, ], ): """ Manages the shared reference index for one or more ``NDWidget`` instances. Stores the current index for each named slider dimension in reference-space units (ex: seconds, depth in µm, Hz). Whenever an index is updated, every ``NDGraphic`` in the manged ``NDWidgets`` are requested to render data at the new indices. Each key in ``ref_ranges`` defines a slider dimension. When adding an ``NDGraphic``, every dimension listed in ``dims`` is either a spatial dimension (listed in ``spatial_dims``) or a slider dimension. A slider dim without a reference range gets an ``AutoRangeContinuous`` sized to the data, so an explicit range is only needed when the slider should map reference-space units to array indices rather than use a one-to-one (identity) mapping. You can also define conceptually identical but *independent* reference spaces by using distinct names, ex: ``"time-1"`` and ``"time-2"`` for two subsets of data that should be sycned independently. Each ``NDGraphic`` then declares the specific ``"time-n"`` space that corresponds to its data, so the widget keeps the two timelines decoupled. Parameters ---------- ref_ranges : dict[str, tuple | RangeContinuous] Mapping of dimension names to range specifications. A 3-tuple ``(start, stop, step)`` creates a :class:`RangeContinuous`. A 1-tuple ``(options,)`` creates a :class:`RangeDiscrete`. Examples -------- Single shared time axis:: ri = ReferenceIndex(ref_ranges={"time": (0, 1000, 1), "depth": (15, 35, 0.5)}) ri.set_dim_index("time", 500) # update one dim and re-render ri.set({"time": 500, "depth": 10}) # update several dims atomically Two independent time axes for data from two different recording sessions:: ri = ReferenceIndex({ "time-1": (0, 3600, 1), # session 1 — 1 h at 1 s resolution "time-s": (0, 1800, 1), # session 2 — 30 min at 1 s resolution }) Each ``NDGraphic`` declares matching names for slider dims to indicate that these should be synced across graphics:: ndw[0, 0].add_nd_image(data_s1, ("time-s1", "row", "col"), ("row", "col")) ndw[0, 1].add_nd_image(data_s2, ("time-s2", "row", "col"), ("row", "col")) """ self._ref_ranges = dict() # current index for each dim self._indices: dict[str, int | float | Any] = dict() self._ndwidgets: list[NDWidget] = list() self.push_dims(ref_ranges) self._indices_changed_handlers = set() # per-NDGraphic fetch update revision. Bumped on every ``cancel_awaiting=True`` # call (display only latest fetch, used during slider drag). A scheduled fetch # carries the revision it was created under and skips setting graphic data # if a newer revision has been requested self._fetch_rev: dict[NDGraphic, int] = dict() # per-graphic queue of pending fetch requests for the serial # path (i.e. ``cancel_awaiting=False``). Used for play, step, programmatic updates, # and LinearSelector. Each entry is ``(indices, rev)``. Emptied by # :meth:`_fetch_request` self._fetch_request_queue: dict[ NDGraphic, deque[tuple[dict[str, Any], int]] ] = dict() # per-graphic flag, indicates whether :meth:`_fetch_request` is currently emptying # ``_fetch_request_queue[ndg]``? Ensures only one coroutine is # alive per graphic. Subsequent ``cancel_awaiting=False`` calls just # append to the queue. self._fetch_request_active: dict[NDGraphic, bool] = dict() @property def ref_ranges(self) -> dict[str, RangeContinuous | RangeDiscrete]: """current reference ranges""" return self._ref_ranges @property def dims(self) -> set[str]: """reference dimensions""" return set(self.ref_ranges.keys()) def _add_ndwidget_(self, ndw: NDWidget): """add an NDWidget instance to be managed by this ReferenceIndex""" from ._ndwidget import NDWidget if not isinstance(ndw, NDWidget): raise TypeError self._ndwidgets.append(ndw)
[docs] def set(self, indices: dict[str, Any], cancel_awaiting: bool = False): """ Set the index for each dimension in indices Parameters ---------- indices: dict[str, Any] indices to set, ``{dim: index}``, in reference-space units. Values are clamped to the reference range of that dim. cancel_awaiting: bool, default ``False`` cancel in-progress fetches, i.e. only display the latest fetch request """ for dim, value in indices.items(): self._indices[dim] = self._clamp(dim, value) self._fetch_indices(cancel_awaiting=cancel_awaiting) self._indices_changed()
@property def ndgraphics(self) -> Iterator[NDGraphic]: """All the NDGraphics that this ReferenceIndex instance manages""" for ndw in self._ndwidgets: yield from ndw.ndgraphics
[docs] def set_dim_index( self, dim: str, index: int | float, cancel_awaiting: bool = False ): """ Set the index for a single dimension and trigger an update. Parameters ---------- dim : str Dimension name. index : int or float New reference-space value for this dimension. cancel_awaiting : bool, default False If True, cancel any in-progress fetch tasks before scheduling a new one. Used only for fast inputs, currently only for the imgui slider so every single intermediate position during a slider drag isn't fetched & rendered. All other methods of fetching data (play, step buttons, LinearSelector, programmatic updates) use cancel_awaiting=False to display every data fetch. """ self._check_has_dim(dim) self._indices[dim] = self._clamp(dim, index) for ndg in self.ndgraphics: # set only for NDGraphics that have this dim if dim in ndg.dims: self._schedule_fetch(ndg, cancel_awaiting=cancel_awaiting) self._indices_changed()
def _clamp(self, dim: str, value: int | float): """clamp the given index value within the valid range for this dimension""" if isinstance(self.ref_ranges[dim], RangeContinuous): return max( min(value, self.ref_ranges[dim].stop - self.ref_ranges[dim].step), self.ref_ranges[dim].start, ) return value def _fetch_indices(self, cancel_awaiting: bool = False): """ Schedule a fetch for every NDGraphic. """ for g in self.ndgraphics: self._schedule_fetch(g, cancel_awaiting=cancel_awaiting) def _schedule_fetch(self, ndg: NDGraphic, cancel_awaiting: bool = False): """ Schedule fetch for an NDGraphic This entry point has 2 paths: * ``cancel_awaiting=True`` used for fast inputs, currently only for the imgui slider where we don't want to fetch & render every intermediate position during a slider drag. Schedules a new ``_set_indices_`` task via :meth:`_render_request_latest`. Any in-progress tasks skip setting graphic data. ``_fetch_rev`` is used so only the latest revision is rendered. - ``cancel_awaiting=False`` used by play, step button, LinearSelector, programmatic updates. Every request will fetch & render. Requests are queued per graphic and processed in sequence by :meth:`_render_request`. """ if ndg.data is None or ndg.pause or ndg._block_indices: # skip fetch for this graphic return if os.environ.get("DOCS_BUILD") == "1": # the docs gallery draws its frames in a plain loop with no event loop running, so a # task handed to the scheduler is never picked up and the graphic never updates run_sync(ndg._set_indices_()) return task_name = f"ndw-fetch:{type(ndg).__name__}" if ndg.name is not None: task_name = f"{task_name}:{ndg.name}" if cancel_awaiting: # bump revision so older in-progress fetches skip setting graphic data self._fetch_rev[ndg] = self._fetch_rev.get(ndg, 0) + 1 rev = self._fetch_rev[ndg] # add to rendercanvas scheduler loop.add_task(self._fetch_request_latest, ndg, rev, name=task_name) else: rev = self._fetch_rev.get(ndg, 0) # provide index at schedule time so all data is played back sequentially indices = {d: self._indices[d] for d in ndg.slicer.slider_dims} self._fetch_request_queue.setdefault(ndg, deque()).append((indices, rev)) # one queue per graphic # if one is already running the appended entry will be picked up by it if not self._fetch_request_active.get(ndg, False): self._fetch_request_active[ndg] = True loop.add_task(self._fetch_request, ndg, name=task_name) async def _fetch_request(self, graphic: "NDGraphic"): """ Process ``_fetch_request_queue[graphic]`` one entry at a time. Each ``_set_indices_`` is awaited fully before the next entry is popped, so only one ``_set_indices_`` is in-progress per graphic from this path. A concurrent :meth:`_fetch_request_latest` for the same graphic can still cancel an in-progress fetch; the resulting :class:`CancelledError` is dropped. """ try: queue = self._fetch_request_queue[graphic] while queue: indices, rev = queue.popleft() if rev < self._fetch_rev.get(graphic, 0): # a rapid-fire request superseded this queued entry; skip continue try: await graphic._set_indices_(indices) except CancelledError: # concurrent _fetch_request_latest canceled our read on ``data`` pass del self._fetch_request_queue[graphic] finally: self._fetch_request_active[graphic] = False async def _fetch_request_latest(self, graphic: "NDGraphic", rev: int): """ Schedule one ``_set_indices_`` task. Older still-running tasks skip their graphic data write when ``rev < current``. Some ``data`` objects cancel the previous in-flight read when a new index is requested; the resulting :class:`CancelledError` is dropped. """ if rev < self._fetch_rev.get(graphic, 0): # a newer rapid-fire request superseded us; drop the write return try: await graphic._set_indices_() except CancelledError: # ``data`` cancelled this read in favour of a newer one pass def __getitem__(self, dim): self._check_has_dim(dim) return self._indices[dim] def _check_has_dim(self, dim): if dim not in self.dims: raise KeyError( f"provided dimension: {dim} has no associated ReferenceRange in this ReferenceIndex, valid dims in this ReferenceIndex are: {self.dims}" )
[docs] def pop_dims(self, *dims: str) -> dict[str, RangeContinuous | RangeDiscrete]: """ Remove slider dims, i.e. unregister their reference ranges. A dim can only be removed once no ``NDGraphic`` uses it as a slider dim, since its reference range and current index are what those graphics are sliced with. Delete those graphics first using ``nd_subplot.delete_nd_graphic()``. Every given dim is checked before any of them are removed, so nothing is removed if one of them is still in use. The slider for each removed dim is also removed from the UI of every ``NDWidget`` managed by this ``ReferenceIndex``. Parameters ---------- dims: str names of the dims to remove Returns ------- dict[str, RangeContinuous | RangeDiscrete] the removed reference ranges, ``{dim_name: range}``, can be passed back to :meth:`push_dims` Raises ------ KeyError if a given dim has no reference range in this ``ReferenceIndex`` ValueError if an ``NDGraphic`` still uses one of the given dims as a slider dim """ # a dim given more than once must not be popped twice dims = tuple(dict.fromkeys(dims)) for dim in dims: self._check_has_dim(dim) in_use = [g for g in self.ndgraphics if dim in g.slider_dims] if in_use: names = ", ".join( f"{type(g).__name__}: {g.name if g.name is not None else hex(id(g))}" for g in in_use ) raise ValueError( f"cannot pop dim: {dim}, it is a slider dim of the following NDGraphics: {names}\n" f"delete them first using `nd_subplot.delete_nd_graphic()`" ) popped = dict() for dim in dims: popped[dim] = self._ref_ranges.pop(dim) self._indices.pop(dim) # remove the slider from the imgui UI of each NDWidget window for ndw in self._ndwidgets: ndw._sliders_ui.pop_dim(dim) self._indices_changed() return popped
[docs] def push_dims( self, ref_ranges: dict[ str, tuple[Number, Number, Number] | tuple[Any] | RangeContinuous, ], ): """ Add reference ranges, i.e. register new slider dims. The index of each new dim is initialized to the start of its range, and a slider for it is added to the UI of every ``NDWidget`` managed by this ``ReferenceIndex``. Parameters ---------- ref_ranges: dict[str, tuple | RangeContinuous | RangeDiscrete] Mapping of dim names to range specifications. A 3-tuple ``(start, stop, step)`` creates a :class:`RangeContinuous`, a 1-tuple ``(options,)`` creates a :class:`RangeDiscrete`, and a ``RangeContinuous`` or ``RangeDiscrete`` instance is used as given. An existing dim of the same name is replaced. """ for name, r in ref_ranges.items(): if isinstance(r, (RangeContinuous, RangeDiscrete)): self._ref_ranges[name] = r elif len(r) == 3: # assume start, stop, step self._ref_ranges[name] = RangeContinuous(*r) elif len(r) == 1: # assume just options self._ref_ranges[name] = RangeDiscrete(*r) else: raise ValueError( f"ref_ranges must be a mapping of dimension names to range specifications, " f"see the docstring, you have passed: {ref_ranges}" ) rr = self._ref_ranges[name] if isinstance(rr, AutoRangeContinuous): self._indices[name] = 0 elif isinstance(rr, RangeContinuous): self._indices[name] = rr.start elif isinstance(rr, RangeDiscrete): # start at the first option self._indices[name] = rr.options[0] # set imgui UI for each NDWidget window for ndw in self._ndwidgets: ndw._sliders_ui.push_dim(name)
[docs] def add_event_handler(self, handler: Callable, event: str = "indices"): """ Register an event handler that is called whenever the indices change. Parameters ---------- handler: Callable callback function, must take a tuple of int as the only argument. This tuple will be the `indices` event: str, "indices" the only supported valid is "indices" Example ------- .. code-block:: py def my_handler(indices): print(indices) # example prints: {"t": 100, "z": 15} if the index has 2 reference spaces "t" and "z" # create an NDWidget ndw = NDWidget(...) # add event handler ndw.indices.add_event_handler(my_handler) """ if event != "indices": raise ValueError("`indices` is the only event supported by `GlobalIndex`") self._indices_changed_handlers.add(handler)
[docs] def remove_event_handler(self, handler: Callable): """Remove a registered event handler""" self._indices_changed_handlers.remove(handler)
[docs] def clear_event_handlers(self): """Clear all registered event handlers""" self._indices_changed_handlers.clear()
def _indices_changed(self): # calls indices changed handlers for f in self._indices_changed_handlers: f(self._indices) def __iter__(self): for index in self._indices.items(): yield index def __len__(self): return len(self._indices) def __eq__(self, other): return self._indices == other def __repr__(self): return f"Global Index: {self._indices}" def __str__(self): return str(self._indices)