Skip to content

Glyph Base Class#

The Glyph class is the base class for all cleopatra visualization glyphs. It provides shared infrastructure for figure/axes management, color scale normalization, colorbar creation, tick control, point overlays, and animation saving.

Both ArrayGlyph and MeshGlyph inherit from Glyph.

Class Documentation#

cleopatra.glyph.Glyph #

Base class for cleopatra visualization glyphs.

Handles figure/axes management, default options, color scale normalization, colorbar creation, tick control, point overlays, and animation saving. Subclasses implement the actual rendering.

Parameters:

Name Type Description Default
default_options dict

Default plot options dict. Subclasses provide their own defaults merged with STYLE_DEFAULTS.

required
fig Figure

Pre-existing matplotlib figure. Default is None.

None
ax Axes

Pre-existing matplotlib axes. Default is None.

None
**kwargs

Override any key in default_options.

{}

Examples:

  • Create a Glyph and override the colormap:
    >>> from cleopatra.glyph import Glyph
    >>> from cleopatra.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts["vmin"] = None
    >>> opts["vmax"] = None
    >>> g = Glyph(default_options=opts, cmap="plasma")
    >>> g.default_options["cmap"]
    'plasma'
    
  • Provide a pre-existing figure and axes:
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyph import Glyph
    >>> from cleopatra.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts["vmin"] = None
    >>> opts["vmax"] = None
    >>> fig, ax = plt.subplots()
    >>> g = Glyph(default_options=opts, fig=fig, ax=ax)
    >>> g.fig is fig
    True
    >>> g.ax is ax
    True
    
See Also

cleopatra.array_glyph.ArrayGlyph: Glyph subclass for 2D/3D arrays. cleopatra.mesh_glyph.MeshGlyph: Glyph subclass for unstructured meshes.

Source code in src/cleopatra/glyph.py
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
class Glyph:
    """Base class for cleopatra visualization glyphs.

    Handles figure/axes management, default options, color scale
    normalization, colorbar creation, tick control, point overlays,
    and animation saving. Subclasses implement the actual rendering.

    Args:
        default_options: Default plot options dict. Subclasses provide
            their own defaults merged with `STYLE_DEFAULTS`.
        fig: Pre-existing matplotlib figure. Default is None.
        ax: Pre-existing matplotlib axes. Default is None.
        **kwargs: Override any key in `default_options`.

    Examples:
        - Create a Glyph and override the colormap:
            ```python
            >>> from cleopatra.glyph import Glyph
            >>> from cleopatra.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts["vmin"] = None
            >>> opts["vmax"] = None
            >>> g = Glyph(default_options=opts, cmap="plasma")
            >>> g.default_options["cmap"]
            'plasma'

            ```
        - Provide a pre-existing figure and axes:
            ```python
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyph import Glyph
            >>> from cleopatra.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts["vmin"] = None
            >>> opts["vmax"] = None
            >>> fig, ax = plt.subplots()
            >>> g = Glyph(default_options=opts, fig=fig, ax=ax)
            >>> g.fig is fig
            True
            >>> g.ax is ax
            True

            ```

    See Also:
        cleopatra.array_glyph.ArrayGlyph: Glyph subclass for
            2D/3D arrays.
        cleopatra.mesh_glyph.MeshGlyph: Glyph subclass for
            unstructured meshes.
    """

    def __init__(
        self,
        default_options: dict,
        fig: Figure = None,
        ax: Axes = None,
        **kwargs,
    ):
        self._default_options = default_options.copy()
        self._merge_kwargs(kwargs)
        self._vmin: float | None = None
        self._vmax: float | None = None
        self.ticks_spacing: float | None = None
        if fig is not None:
            self.fig = fig
            self.ax = ax
        else:
            self.fig = None
            self.ax = None

    @property
    def vmin(self) -> float | None:
        """Minimum value for color scaling."""
        return self._vmin

    @property
    def vmax(self) -> float | None:
        """Maximum value for color scaling."""
        return self._vmax

    @property
    def default_options(self) -> dict:
        """Default plot options."""
        return self._default_options

    @property
    def anim(self) -> FuncAnimation:
        """Animation object created by `animate()`."""
        if hasattr(self, "_anim") and self._anim is not None:
            return self._anim
        raise ValueError(
            "Please first use the animate method to create the animation object"
        )

    def _merge_kwargs(self, kwargs: dict) -> None:
        """Validate and merge keyword arguments into default_options."""
        for key, val in kwargs.items():
            if key not in self._default_options:
                raise ValueError(
                    f"The given keyword argument:{key} is not correct, "
                    f"possible parameters are, {list(self._default_options.keys())}"
                )
            else:
                self._default_options[key] = val

    def create_figure_axes(self) -> tuple[Figure, Axes]:
        """Create a new figure and axes from default_options.

        Uses the `figsize` key from `default_options` to set the
        figure dimensions.

        Returns:
            tuple[Figure, Axes]: The created figure and axes.

        Examples:
            - Create a figure with custom size:
                ```python
                >>> from cleopatra.glyph import Glyph
                >>> from cleopatra.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": None, "vmax": None})
                >>> g = Glyph(default_options=opts, figsize=(12, 4))
                >>> fig, ax = g.create_figure_axes()
                >>> fig.get_size_inches()
                array([12.,  4.])

                ```
        """
        fig, ax = plt.subplots(figsize=self.default_options["figsize"])
        return fig, ax

    def get_ticks(self) -> np.ndarray:
        """Compute colorbar tick locations from default_options.

        Uses `vmin`, `vmax`, and `ticks_spacing` from
        `default_options` to generate evenly-spaced tick positions.

        Returns:
            np.ndarray: Array of tick positions.

        Examples:
            - Compute ticks for a 0-10 range with spacing of 2:
                ```python
                >>> from cleopatra.glyph import Glyph
                >>> from cleopatra.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": 0.0, "vmax": 10.0, "ticks_spacing": 2.0})
                >>> g = Glyph(default_options=opts)
                >>> g.get_ticks()
                array([ 0.,  2.,  4.,  6.,  8., 10.])

                ```
        """
        ticks_spacing = self.default_options["ticks_spacing"]
        vmax = self.default_options["vmax"]
        vmin = self.default_options["vmin"]
        ticks = np.arange(vmin, vmax + ticks_spacing, ticks_spacing)
        # If vmax is not evenly divisible by spacing, append one more tick.
        remainder = np.round(math.remainder(vmax, ticks_spacing), 3)
        if remainder != 0:
            ticks = np.append(
                ticks,
                [int(vmax / ticks_spacing) * ticks_spacing + ticks_spacing],
            )
        return ticks

    def _create_norm_and_cbar_kw(
        self, ticks: np.ndarray
    ) -> tuple[colors.Normalize | None, dict]:
        """Create a matplotlib Normalize and colorbar kwargs.

        Honours the `color_scale` option — a `cleopatra.styles.ColorScale`
        member or its string value (case-insensitive): `linear` / `power` /
        `sym-lognorm` / `boundary-norm` / `midpoint` — and the
        xarray-aligned `levels` and `extend` options when present in
        `default_options`. An unrecognised `color_scale` (including a
        non-string such as an int) raises `ValueError`.

        Behaviour for `levels`:

        * `levels` is `None` (default) — continuous norm based on
          `color_scale`.
        * `levels` is an `int` and `color_scale` is the default
          `"linear"` — switch to a `BoundaryNorm` with `levels`
          linearly-spaced edges between `vmin` and `vmax`.
        * `levels` is a sequence and `color_scale` is `"linear"` —
          use the sequence as explicit bin edges in a `BoundaryNorm`.
        * `levels` is set and `color_scale` is `"boundary-norm"`
          with no explicit `bounds` — treat `levels` as the bounds.
        * Otherwise (`color_scale` is some other enum value) — the
          user's choice wins; `levels` is left for the caller to
          forward to `contour` / `contourf`.

        Behaviour for `extend`: when present and non-None, the value
        is forwarded to the colorbar via `cbar_kw["extend"]`. The
        auto-resolution (`"both"` when `levels` is set, else
        `"neither"`) happens here only when `extend` is `None`.

        Args:
            ticks: Tick positions for the colorbar.

        Returns:
            tuple[Normalize or None, dict]: The norm (None for linear)
                and colorbar keyword arguments.

        Raises:
            ValueError: If `default_options["color_scale"]` is not a
                recognised `cleopatra.styles.ColorScale` value.

        Examples:
            - Linear colour scale with no levels gives `norm=None`
                and ticks forwarded straight through:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyph import Glyph
                >>> from cleopatra.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": 0.0, "vmax": 10.0})
                >>> g = Glyph(default_options=opts)
                >>> norm, cbar_kw = g._create_norm_and_cbar_kw(np.array([0.0, 5.0, 10.0]))
                >>> norm is None
                True
                >>> cbar_kw["extend"]
                'neither'
                >>> [float(t) for t in cbar_kw["ticks"]]
                [0.0, 5.0, 10.0]

                ```
            - With `levels` set and the default linear scale, a
                `BoundaryNorm` is built and `extend` defaults to
                `"both"`:
                ```python
                >>> import numpy as np
                >>> from cleopatra.glyph import Glyph
                >>> from cleopatra.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": 0.0, "vmax": 10.0, "levels": 5})
                >>> g = Glyph(default_options=opts)
                >>> norm, cbar_kw = g._create_norm_and_cbar_kw(np.array([0.0, 5.0, 10.0]))
                >>> norm is None
                False
                >>> cbar_kw["extend"]
                'both'
                >>> [float(t) for t in cbar_kw["ticks"]]
                [0.0, 2.5, 5.0, 7.5, 10.0]

                ```
        """
        raw_scale = self.default_options["color_scale"]
        try:
            color_scale = ColorScale(raw_scale)
        except ValueError as e:
            valid = ", ".join(repr(m.value) for m in ColorScale)
            raise ValueError(
                f"Invalid color_scale {raw_scale!r}. Expected one of "
                f"{valid} (or a cleopatra.styles.ColorScale member)."
            ) from e
        vmin = ticks[0]
        vmax = ticks[-1]
        levels = self.default_options.get("levels")
        bounds_from_levels = self._levels_to_bounds(levels, vmin, vmax)

        if color_scale == ColorScale.LINEAR:
            if bounds_from_levels is not None:
                norm = colors.BoundaryNorm(
                    boundaries=bounds_from_levels, ncolors=256
                )
                cbar_kw = {"ticks": bounds_from_levels}
            else:
                norm = None
                cbar_kw = {"ticks": ticks}
        elif color_scale == ColorScale.POWER:
            norm = colors.PowerNorm(
                gamma=self.default_options["gamma"], vmin=vmin, vmax=vmax
            )
            cbar_kw = {"ticks": ticks}
        elif color_scale == ColorScale.SYM_LOGNORM:
            norm = colors.SymLogNorm(
                linthresh=self.default_options["line_threshold"],
                linscale=self.default_options["line_scale"],
                base=np.e,
                vmin=vmin,
                vmax=vmax,
            )
            formatter = LogFormatter(10, labelOnlyBase=False)
            cbar_kw = {"ticks": ticks, "format": formatter}
        elif color_scale == ColorScale.BOUNDARY_NORM:
            explicit_bounds = self.default_options["bounds"]
            if explicit_bounds:
                bounds = explicit_bounds
                cbar_kw = {"ticks": explicit_bounds}
            elif bounds_from_levels is not None:
                bounds = bounds_from_levels
                cbar_kw = {"ticks": bounds_from_levels}
            else:
                bounds = ticks
                cbar_kw = {"ticks": ticks}
            norm = colors.BoundaryNorm(boundaries=bounds, ncolors=256)
        elif color_scale == ColorScale.MIDPOINT:
            norm = MidpointNormalize(
                midpoint=self.default_options["midpoint"],
                vmin=vmin,
                vmax=vmax,
            )
            cbar_kw = {"ticks": ticks}
        else:  # pragma: no cover - a ColorScale member without a branch
            raise ValueError(
                f"No norm branch implemented for color_scale={color_scale!r}."
            )

        extend = self.default_options.get("extend")
        if extend is None:
            extend_effective = "both" if levels is not None else "neither"
        else:
            extend_effective = extend
        cbar_kw["extend"] = extend_effective

        return norm, cbar_kw

    @staticmethod
    def _levels_to_bounds(
        levels: int | list[float] | np.ndarray | None,
        vmin: float,
        vmax: float,
    ) -> np.ndarray | None:
        """Convert the `levels` option to an array of bin edges.

        Returns `None` when no levels are configured, signalling that
        the caller should fall back to the continuous norm path.

        Args:
            levels: Number of levels (`int`), explicit edges
                (`list` / `ndarray`), or `None` for no
                discretisation.
            vmin: Lower colour limit. Used when `levels` is an int to
                build the linspace.
            vmax: Upper colour limit. Used when `levels` is an int to
                build the linspace.

        Returns:
            np.ndarray or None: Sorted ascending array of bin edges, or
                `None` when `levels` is `None`.

        Raises:
            ValueError: If `levels` is an integer outside the range
                `[2, MAX_DISCRETE_LEVELS]` (a single edge cannot form a
                `BoundaryNorm`, and an enormous count would OOM
                `np.linspace`).

        Examples:
            - Integer `levels` becomes a `linspace` between
                `vmin` and `vmax`:
                ```python
                >>> from cleopatra.glyph import Glyph
                >>> bounds = Glyph._levels_to_bounds(5, 0.0, 10.0)
                >>> [float(b) for b in bounds]
                [0.0, 2.5, 5.0, 7.5, 10.0]

                ```
            - A sequence is sorted ascending and returned as a float
                `ndarray`; `None` short-circuits to `None`:
                ```python
                >>> from cleopatra.glyph import Glyph
                >>> bounds = Glyph._levels_to_bounds([10.0, 0.0, 5.0], 0.0, 10.0)
                >>> [float(b) for b in bounds]
                [0.0, 5.0, 10.0]
                >>> Glyph._levels_to_bounds(None, 0.0, 10.0) is None
                True

                ```
        """
        bounds: np.ndarray | None
        if levels is None:
            bounds = None
        elif isinstance(levels, (int, np.integer)) and not isinstance(
            levels, bool
        ):
            n = int(levels)
            if not 2 <= n <= MAX_DISCRETE_LEVELS:
                raise ValueError(
                    f"`levels` as an integer must be between 2 and "
                    f"{MAX_DISCRETE_LEVELS}, got {n}."
                )
            bounds = np.linspace(float(vmin), float(vmax), n)
        else:
            bounds = np.sort(np.asarray(levels, dtype=float))
        return bounds

    def create_color_bar(self, ax: Axes, im: Any, cbar_kw: dict) -> Colorbar:
        """Create a colorbar with full customization from default_options.

        Reads `cbar_length`, `cbar_orientation`, `cbar_label`,
        `cbar_label_size`, and `cbar_label_location` from
        `default_options` to configure the colorbar. When the optional
        `cbar_kwargs` entry is present in `default_options` (an
        xarray-aligned dict-of-overrides), its keys are merged over the
        defaults so the user wins on any collision (e.g. `label`,
        `shrink`, `orientation`, `ticks`, `extend`).

        `cbar_kwargs` is read from `self.default_options["cbar_kwargs"]`.
        Set it via the constructor or `plot` kwargs of the calling
        glyph subclass. Keys recognised by `matplotlib.pyplot.colorbar`
        — `label`, `shrink`, `aspect`, `orientation`, `pad`,
        `ticks`, `extend` — are forwarded; `label` is special-cased
        so that label-size and label-location styling from
        `default_options` are still applied.

        Args:
            ax: Matplotlib axes.
            im: The mappable (image or contour) to attach the
                colorbar to.
            cbar_kw: Colorbar keyword arguments (ticks, format,
                extend, etc.) computed by
                `_create_norm_and_cbar_kw`.

        Returns:
            Colorbar: The created colorbar.

        Raises:
            TypeError: If `default_options["cbar_kwargs"]` is set
                but is not a `dict`.

        Examples:
            - Create a colorbar with a custom label:
                ```python
                >>> import numpy as np
                >>> import matplotlib.pyplot as plt
                >>> from cleopatra.glyph import Glyph
                >>> from cleopatra.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": None, "vmax": None})
                >>> g = Glyph(default_options=opts, cbar_label="Depth [m]")
                >>> fig, ax = plt.subplots()
                >>> im = ax.imshow(np.arange(9).reshape(3, 3))
                >>> cbar = g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
                >>> cbar.orientation
                'vertical'

                ```
            - User-supplied `cbar_kwargs` win on collision and
                `label` is applied via `set_label`:
                ```python
                >>> import numpy as np
                >>> import matplotlib.pyplot as plt
                >>> from cleopatra.glyph import Glyph
                >>> from cleopatra.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({
                ...     "vmin": None,
                ...     "vmax": None,
                ...     "cbar_kwargs": {"label": "User Label", "orientation": "horizontal"},
                ... })
                >>> g = Glyph(default_options=opts, cbar_label="Default Label")
                >>> fig, ax = plt.subplots()
                >>> im = ax.imshow(np.arange(9).reshape(3, 3))
                >>> cbar = g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
                >>> cbar.orientation
                'horizontal'
                >>> cbar.ax.get_xlabel() or cbar.ax.get_ylabel()
                'User Label'

                ```
            - Non-dict `cbar_kwargs` raises `TypeError`:
                ```python
                >>> import numpy as np
                >>> import matplotlib.pyplot as plt
                >>> from cleopatra.glyph import Glyph
                >>> from cleopatra.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": None, "vmax": None, "cbar_kwargs": "oops"})
                >>> g = Glyph(default_options=opts)
                >>> fig, ax = plt.subplots()
                >>> im = ax.imshow(np.arange(9).reshape(3, 3))
                >>> g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
                Traceback (most recent call last):
                    ...
                TypeError: cbar_kwargs must be a dict of colorbar keyword arguments, got str.

                ```
        """
        fig = ax.figure
        is_subplot = len(fig.axes) > 1
        merged_kw = {
            "shrink": self.default_options["cbar_length"],
            "orientation": self.default_options["cbar_orientation"],
            "use_gridspec": not is_subplot,
        }
        merged_kw.update(cbar_kw)
        # Pull the user-supplied `label` (if any) out of cbar_kwargs
        # before forwarding to `fig.colorbar` so we can apply it via
        # `cbar.set_label` and preserve label-size/location styling.
        user_kwargs = self.default_options.get("cbar_kwargs") or {}
        if not isinstance(user_kwargs, dict):
            raise TypeError(
                "cbar_kwargs must be a dict of colorbar keyword "
                f"arguments, got {type(user_kwargs).__name__}."
            )
        user_kwargs = dict(user_kwargs)
        user_label = user_kwargs.pop("label", None)
        merged_kw.update(user_kwargs)
        cbar = fig.colorbar(im, ax=ax, **merged_kw)
        cbar.ax.tick_params(labelsize=10)
        label_text = (
            user_label if user_label is not None
            else self.default_options["cbar_label"]
        )
        cbar.set_label(
            label_text,
            fontsize=self.default_options["cbar_label_size"],
            loc=self.default_options["cbar_label_location"],
        )
        return cbar

    def adjust_ticks(
        self,
        axis: str,
        multiply_value: float | int = 1,
        add_value: float | int = 0,
        fmt: str = "{0:g}",
        visible: bool = True,
    ) -> None:
        """Adjust the axis tick labels with a linear transformation.

        Applies `tick_value * multiply_value + add_value` to each
        tick, formatted with `fmt`. Useful for converting pixel
        coordinates to real-world units.

        Args:
            axis: `"x"` or `"y"`.
            multiply_value: Multiplier for tick values. Default is 1.
            add_value: Offset added to tick values. Default is 0.
            fmt: Format string for tick labels.
                Default is `"{0:g}"`.
            visible: Whether the axis is visible. Default is True.

        Examples:
            - Scale x-axis ticks by 100 and offset by 5:
                ```python
                >>> import matplotlib.pyplot as plt
                >>> from cleopatra.glyph import Glyph
                >>> from cleopatra.styles import DEFAULT_OPTIONS
                >>> opts = DEFAULT_OPTIONS.copy()
                >>> opts.update({"vmin": None, "vmax": None})
                >>> g = Glyph(default_options=opts)
                >>> fig, ax = plt.subplots()
                >>> _ = ax.plot([0, 1, 2], [0, 1, 2])
                >>> g.fig, g.ax = fig, ax
                >>> g.adjust_ticks(axis="x", multiply_value=100, add_value=5)

                ```
        """
        if axis == "x":
            ticks_fn = ticker.FuncFormatter(
                lambda x, pos: fmt.format(x * multiply_value + add_value)
            )
            self.ax.xaxis.set_major_formatter(ticks_fn)
        else:
            ticks_fn = ticker.FuncFormatter(
                lambda y, pos: fmt.format(y * multiply_value + add_value)
            )
            self.ax.yaxis.set_major_formatter(ticks_fn)

        if not visible:
            if axis == "x":
                self.ax.get_xaxis().set_visible(visible)
            else:
                self.ax.get_yaxis().set_visible(visible)

    @staticmethod
    def _plot_point_values(ax, point_table: np.ndarray, pid_color, pid_size):
        """Plot point value labels on the axes."""
        write_points = lambda x: ax.text(
            x[2],
            x[1],
            x[0],
            ha="center",
            va="center",
            color=pid_color,
            fontsize=pid_size,
        )
        return list(map(write_points, point_table))

    def save_animation(self, path: str, fps: int = 2) -> None:
        """Save the animation to a file.

        The output format is determined by the file extension. GIF uses
        `PillowWriter`; mov/avi/mp4 require FFmpeg to be installed.

        Args:
            path: Output file path. Extension determines format.
                Supported: gif, mov, avi, mp4.
            fps: Frames per second. Default is 2.

        Raises:
            ValueError: If the file format is not supported.

        Examples:
            - Check the supported video formats:
                ```python
                >>> from cleopatra.glyph import SUPPORTED_VIDEO_FORMAT
                >>> sorted(SUPPORTED_VIDEO_FORMAT)
                ['avi', 'gif', 'mov', 'mp4']

                ```
        """
        video_format = path.split(".")[-1]
        if video_format not in SUPPORTED_VIDEO_FORMAT:
            raise ValueError(
                f"The given extension {video_format} implies a format that is "
                f"not supported, only {SUPPORTED_VIDEO_FORMAT} are supported"
            )

        if video_format == "gif":
            writer_gif = animation.PillowWriter(fps=fps)
            self.anim.save(path, writer=writer_gif)
        else:
            try:
                writer_video = animation.FFMpegWriter(fps=fps, bitrate=1800)
                self.anim.save(path, writer=writer_video)
            except FileNotFoundError as e:
                raise FileNotFoundError(
                    "FFmpeg not found. Please visit https://ffmpeg.org/ "
                    "and download a version compatible with your OS."
                ) from e

anim property #

Animation object created by animate().

default_options property #

Default plot options.

vmax property #

Maximum value for color scaling.

vmin property #

Minimum value for color scaling.

adjust_ticks(axis, multiply_value=1, add_value=0, fmt='{0:g}', visible=True) #

Adjust the axis tick labels with a linear transformation.

Applies tick_value * multiply_value + add_value to each tick, formatted with fmt. Useful for converting pixel coordinates to real-world units.

Parameters:

Name Type Description Default
axis str

"x" or "y".

required
multiply_value float | int

Multiplier for tick values. Default is 1.

1
add_value float | int

Offset added to tick values. Default is 0.

0
fmt str

Format string for tick labels. Default is "{0:g}".

'{0:g}'
visible bool

Whether the axis is visible. Default is True.

True

Examples:

  • Scale x-axis ticks by 100 and offset by 5:
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyph import Glyph
    >>> from cleopatra.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": None, "vmax": None})
    >>> g = Glyph(default_options=opts)
    >>> fig, ax = plt.subplots()
    >>> _ = ax.plot([0, 1, 2], [0, 1, 2])
    >>> g.fig, g.ax = fig, ax
    >>> g.adjust_ticks(axis="x", multiply_value=100, add_value=5)
    
Source code in src/cleopatra/glyph.py
def adjust_ticks(
    self,
    axis: str,
    multiply_value: float | int = 1,
    add_value: float | int = 0,
    fmt: str = "{0:g}",
    visible: bool = True,
) -> None:
    """Adjust the axis tick labels with a linear transformation.

    Applies `tick_value * multiply_value + add_value` to each
    tick, formatted with `fmt`. Useful for converting pixel
    coordinates to real-world units.

    Args:
        axis: `"x"` or `"y"`.
        multiply_value: Multiplier for tick values. Default is 1.
        add_value: Offset added to tick values. Default is 0.
        fmt: Format string for tick labels.
            Default is `"{0:g}"`.
        visible: Whether the axis is visible. Default is True.

    Examples:
        - Scale x-axis ticks by 100 and offset by 5:
            ```python
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyph import Glyph
            >>> from cleopatra.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": None, "vmax": None})
            >>> g = Glyph(default_options=opts)
            >>> fig, ax = plt.subplots()
            >>> _ = ax.plot([0, 1, 2], [0, 1, 2])
            >>> g.fig, g.ax = fig, ax
            >>> g.adjust_ticks(axis="x", multiply_value=100, add_value=5)

            ```
    """
    if axis == "x":
        ticks_fn = ticker.FuncFormatter(
            lambda x, pos: fmt.format(x * multiply_value + add_value)
        )
        self.ax.xaxis.set_major_formatter(ticks_fn)
    else:
        ticks_fn = ticker.FuncFormatter(
            lambda y, pos: fmt.format(y * multiply_value + add_value)
        )
        self.ax.yaxis.set_major_formatter(ticks_fn)

    if not visible:
        if axis == "x":
            self.ax.get_xaxis().set_visible(visible)
        else:
            self.ax.get_yaxis().set_visible(visible)

create_color_bar(ax, im, cbar_kw) #

Create a colorbar with full customization from default_options.

Reads cbar_length, cbar_orientation, cbar_label, cbar_label_size, and cbar_label_location from default_options to configure the colorbar. When the optional cbar_kwargs entry is present in default_options (an xarray-aligned dict-of-overrides), its keys are merged over the defaults so the user wins on any collision (e.g. label, shrink, orientation, ticks, extend).

cbar_kwargs is read from self.default_options["cbar_kwargs"]. Set it via the constructor or plot kwargs of the calling glyph subclass. Keys recognised by matplotlib.pyplot.colorbarlabel, shrink, aspect, orientation, pad, ticks, extend — are forwarded; label is special-cased so that label-size and label-location styling from default_options are still applied.

Parameters:

Name Type Description Default
ax Axes

Matplotlib axes.

required
im Any

The mappable (image or contour) to attach the colorbar to.

required
cbar_kw dict

Colorbar keyword arguments (ticks, format, extend, etc.) computed by _create_norm_and_cbar_kw.

required

Returns:

Name Type Description
Colorbar Colorbar

The created colorbar.

Raises:

Type Description
TypeError

If default_options["cbar_kwargs"] is set but is not a dict.

Examples:

  • Create a colorbar with a custom label:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyph import Glyph
    >>> from cleopatra.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": None, "vmax": None})
    >>> g = Glyph(default_options=opts, cbar_label="Depth [m]")
    >>> fig, ax = plt.subplots()
    >>> im = ax.imshow(np.arange(9).reshape(3, 3))
    >>> cbar = g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
    >>> cbar.orientation
    'vertical'
    
  • User-supplied cbar_kwargs win on collision and label is applied via set_label:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyph import Glyph
    >>> from cleopatra.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({
    ...     "vmin": None,
    ...     "vmax": None,
    ...     "cbar_kwargs": {"label": "User Label", "orientation": "horizontal"},
    ... })
    >>> g = Glyph(default_options=opts, cbar_label="Default Label")
    >>> fig, ax = plt.subplots()
    >>> im = ax.imshow(np.arange(9).reshape(3, 3))
    >>> cbar = g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
    >>> cbar.orientation
    'horizontal'
    >>> cbar.ax.get_xlabel() or cbar.ax.get_ylabel()
    'User Label'
    
  • Non-dict cbar_kwargs raises TypeError:
    >>> import numpy as np
    >>> import matplotlib.pyplot as plt
    >>> from cleopatra.glyph import Glyph
    >>> from cleopatra.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": None, "vmax": None, "cbar_kwargs": "oops"})
    >>> g = Glyph(default_options=opts)
    >>> fig, ax = plt.subplots()
    >>> im = ax.imshow(np.arange(9).reshape(3, 3))
    >>> g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
    Traceback (most recent call last):
        ...
    TypeError: cbar_kwargs must be a dict of colorbar keyword arguments, got str.
    
Source code in src/cleopatra/glyph.py
def create_color_bar(self, ax: Axes, im: Any, cbar_kw: dict) -> Colorbar:
    """Create a colorbar with full customization from default_options.

    Reads `cbar_length`, `cbar_orientation`, `cbar_label`,
    `cbar_label_size`, and `cbar_label_location` from
    `default_options` to configure the colorbar. When the optional
    `cbar_kwargs` entry is present in `default_options` (an
    xarray-aligned dict-of-overrides), its keys are merged over the
    defaults so the user wins on any collision (e.g. `label`,
    `shrink`, `orientation`, `ticks`, `extend`).

    `cbar_kwargs` is read from `self.default_options["cbar_kwargs"]`.
    Set it via the constructor or `plot` kwargs of the calling
    glyph subclass. Keys recognised by `matplotlib.pyplot.colorbar`
    — `label`, `shrink`, `aspect`, `orientation`, `pad`,
    `ticks`, `extend` — are forwarded; `label` is special-cased
    so that label-size and label-location styling from
    `default_options` are still applied.

    Args:
        ax: Matplotlib axes.
        im: The mappable (image or contour) to attach the
            colorbar to.
        cbar_kw: Colorbar keyword arguments (ticks, format,
            extend, etc.) computed by
            `_create_norm_and_cbar_kw`.

    Returns:
        Colorbar: The created colorbar.

    Raises:
        TypeError: If `default_options["cbar_kwargs"]` is set
            but is not a `dict`.

    Examples:
        - Create a colorbar with a custom label:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyph import Glyph
            >>> from cleopatra.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": None, "vmax": None})
            >>> g = Glyph(default_options=opts, cbar_label="Depth [m]")
            >>> fig, ax = plt.subplots()
            >>> im = ax.imshow(np.arange(9).reshape(3, 3))
            >>> cbar = g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
            >>> cbar.orientation
            'vertical'

            ```
        - User-supplied `cbar_kwargs` win on collision and
            `label` is applied via `set_label`:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyph import Glyph
            >>> from cleopatra.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({
            ...     "vmin": None,
            ...     "vmax": None,
            ...     "cbar_kwargs": {"label": "User Label", "orientation": "horizontal"},
            ... })
            >>> g = Glyph(default_options=opts, cbar_label="Default Label")
            >>> fig, ax = plt.subplots()
            >>> im = ax.imshow(np.arange(9).reshape(3, 3))
            >>> cbar = g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
            >>> cbar.orientation
            'horizontal'
            >>> cbar.ax.get_xlabel() or cbar.ax.get_ylabel()
            'User Label'

            ```
        - Non-dict `cbar_kwargs` raises `TypeError`:
            ```python
            >>> import numpy as np
            >>> import matplotlib.pyplot as plt
            >>> from cleopatra.glyph import Glyph
            >>> from cleopatra.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": None, "vmax": None, "cbar_kwargs": "oops"})
            >>> g = Glyph(default_options=opts)
            >>> fig, ax = plt.subplots()
            >>> im = ax.imshow(np.arange(9).reshape(3, 3))
            >>> g.create_color_bar(ax, im, {"ticks": [0, 4, 8]})
            Traceback (most recent call last):
                ...
            TypeError: cbar_kwargs must be a dict of colorbar keyword arguments, got str.

            ```
    """
    fig = ax.figure
    is_subplot = len(fig.axes) > 1
    merged_kw = {
        "shrink": self.default_options["cbar_length"],
        "orientation": self.default_options["cbar_orientation"],
        "use_gridspec": not is_subplot,
    }
    merged_kw.update(cbar_kw)
    # Pull the user-supplied `label` (if any) out of cbar_kwargs
    # before forwarding to `fig.colorbar` so we can apply it via
    # `cbar.set_label` and preserve label-size/location styling.
    user_kwargs = self.default_options.get("cbar_kwargs") or {}
    if not isinstance(user_kwargs, dict):
        raise TypeError(
            "cbar_kwargs must be a dict of colorbar keyword "
            f"arguments, got {type(user_kwargs).__name__}."
        )
    user_kwargs = dict(user_kwargs)
    user_label = user_kwargs.pop("label", None)
    merged_kw.update(user_kwargs)
    cbar = fig.colorbar(im, ax=ax, **merged_kw)
    cbar.ax.tick_params(labelsize=10)
    label_text = (
        user_label if user_label is not None
        else self.default_options["cbar_label"]
    )
    cbar.set_label(
        label_text,
        fontsize=self.default_options["cbar_label_size"],
        loc=self.default_options["cbar_label_location"],
    )
    return cbar

create_figure_axes() #

Create a new figure and axes from default_options.

Uses the figsize key from default_options to set the figure dimensions.

Returns:

Type Description
tuple[Figure, Axes]

tuple[Figure, Axes]: The created figure and axes.

Examples:

  • Create a figure with custom size:
    >>> from cleopatra.glyph import Glyph
    >>> from cleopatra.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": None, "vmax": None})
    >>> g = Glyph(default_options=opts, figsize=(12, 4))
    >>> fig, ax = g.create_figure_axes()
    >>> fig.get_size_inches()
    array([12.,  4.])
    
Source code in src/cleopatra/glyph.py
def create_figure_axes(self) -> tuple[Figure, Axes]:
    """Create a new figure and axes from default_options.

    Uses the `figsize` key from `default_options` to set the
    figure dimensions.

    Returns:
        tuple[Figure, Axes]: The created figure and axes.

    Examples:
        - Create a figure with custom size:
            ```python
            >>> from cleopatra.glyph import Glyph
            >>> from cleopatra.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": None, "vmax": None})
            >>> g = Glyph(default_options=opts, figsize=(12, 4))
            >>> fig, ax = g.create_figure_axes()
            >>> fig.get_size_inches()
            array([12.,  4.])

            ```
    """
    fig, ax = plt.subplots(figsize=self.default_options["figsize"])
    return fig, ax

get_ticks() #

Compute colorbar tick locations from default_options.

Uses vmin, vmax, and ticks_spacing from default_options to generate evenly-spaced tick positions.

Returns:

Type Description
ndarray

np.ndarray: Array of tick positions.

Examples:

  • Compute ticks for a 0-10 range with spacing of 2:
    >>> from cleopatra.glyph import Glyph
    >>> from cleopatra.styles import DEFAULT_OPTIONS
    >>> opts = DEFAULT_OPTIONS.copy()
    >>> opts.update({"vmin": 0.0, "vmax": 10.0, "ticks_spacing": 2.0})
    >>> g = Glyph(default_options=opts)
    >>> g.get_ticks()
    array([ 0.,  2.,  4.,  6.,  8., 10.])
    
Source code in src/cleopatra/glyph.py
def get_ticks(self) -> np.ndarray:
    """Compute colorbar tick locations from default_options.

    Uses `vmin`, `vmax`, and `ticks_spacing` from
    `default_options` to generate evenly-spaced tick positions.

    Returns:
        np.ndarray: Array of tick positions.

    Examples:
        - Compute ticks for a 0-10 range with spacing of 2:
            ```python
            >>> from cleopatra.glyph import Glyph
            >>> from cleopatra.styles import DEFAULT_OPTIONS
            >>> opts = DEFAULT_OPTIONS.copy()
            >>> opts.update({"vmin": 0.0, "vmax": 10.0, "ticks_spacing": 2.0})
            >>> g = Glyph(default_options=opts)
            >>> g.get_ticks()
            array([ 0.,  2.,  4.,  6.,  8., 10.])

            ```
    """
    ticks_spacing = self.default_options["ticks_spacing"]
    vmax = self.default_options["vmax"]
    vmin = self.default_options["vmin"]
    ticks = np.arange(vmin, vmax + ticks_spacing, ticks_spacing)
    # If vmax is not evenly divisible by spacing, append one more tick.
    remainder = np.round(math.remainder(vmax, ticks_spacing), 3)
    if remainder != 0:
        ticks = np.append(
            ticks,
            [int(vmax / ticks_spacing) * ticks_spacing + ticks_spacing],
        )
    return ticks

save_animation(path, fps=2) #

Save the animation to a file.

The output format is determined by the file extension. GIF uses PillowWriter; mov/avi/mp4 require FFmpeg to be installed.

Parameters:

Name Type Description Default
path str

Output file path. Extension determines format. Supported: gif, mov, avi, mp4.

required
fps int

Frames per second. Default is 2.

2

Raises:

Type Description
ValueError

If the file format is not supported.

Examples:

  • Check the supported video formats:
    >>> from cleopatra.glyph import SUPPORTED_VIDEO_FORMAT
    >>> sorted(SUPPORTED_VIDEO_FORMAT)
    ['avi', 'gif', 'mov', 'mp4']
    
Source code in src/cleopatra/glyph.py
def save_animation(self, path: str, fps: int = 2) -> None:
    """Save the animation to a file.

    The output format is determined by the file extension. GIF uses
    `PillowWriter`; mov/avi/mp4 require FFmpeg to be installed.

    Args:
        path: Output file path. Extension determines format.
            Supported: gif, mov, avi, mp4.
        fps: Frames per second. Default is 2.

    Raises:
        ValueError: If the file format is not supported.

    Examples:
        - Check the supported video formats:
            ```python
            >>> from cleopatra.glyph import SUPPORTED_VIDEO_FORMAT
            >>> sorted(SUPPORTED_VIDEO_FORMAT)
            ['avi', 'gif', 'mov', 'mp4']

            ```
    """
    video_format = path.split(".")[-1]
    if video_format not in SUPPORTED_VIDEO_FORMAT:
        raise ValueError(
            f"The given extension {video_format} implies a format that is "
            f"not supported, only {SUPPORTED_VIDEO_FORMAT} are supported"
        )

    if video_format == "gif":
        writer_gif = animation.PillowWriter(fps=fps)
        self.anim.save(path, writer=writer_gif)
    else:
        try:
            writer_video = animation.FFMpegWriter(fps=fps, bitrate=1800)
            self.anim.save(path, writer=writer_video)
        except FileNotFoundError as e:
            raise FileNotFoundError(
                "FFmpeg not found. Please visit https://ffmpeg.org/ "
                "and download a version compatible with your OS."
            ) from e