diff --git a/docs/migration.md b/docs/migration.md index 8263debb..4398256d 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -102,6 +102,29 @@ Only the `DeprecationWarning` that steered you toward `ColorBar` is gone. The ty `colorbar=ColorBar(...)` form (`cleopatra.styling.colorbar.ColorBar`) is still preferred and wins when both are given. +### RGB band preparation → `RgbBands` + +`ArrayGlyph`'s **constructor** no longer takes the four loose RGB keywords. Bundle them into an `RgbBands` +(importable from `cleopatra.glyphs.gridded.array_glyph`) passed as `rgb_bands=`: + +| Old | New | +| --- | --- | +| `ArrayGlyph(arr, rgb=[r, g, b])` | `ArrayGlyph(arr, rgb_bands=RgbBands([r, g, b]))` | +| `ArrayGlyph(arr, rgb=..., surface_reflectance=..., cutoff=..., percentile=...)` | `ArrayGlyph(arr, rgb_bands=RgbBands([...], surface_reflectance=..., cutoff=..., percentile=...))` | + +```python +# before +ArrayGlyph(sentinel_2, rgb=[3, 2, 1], surface_reflectance=10000, cutoff=[0.3, 0.3, 0.3]) + +# after +from cleopatra.glyphs.gridded.array_glyph import RgbBands +ArrayGlyph(sentinel_2, rgb_bands=RgbBands([3, 2, 1], surface_reflectance=10000, cutoff=[0.3, 0.3, 0.3])) +``` + +The lower-level `ArrayGlyph.prepare_array(...)` and `ArrayGlyph.scale_percentile(...)` utilities are unchanged — +they still accept the loose `rgb` / `surface_reflectance` / `cutoff` / `percentile` keywords (they now build an +`RgbBands` internally). + --- ## Subpackage restructure diff --git a/docs/notebooks/array_glyph/array_glyph_examples.ipynb b/docs/notebooks/array_glyph/array_glyph_examples.ipynb index b9401efa..f9a16d40 100644 --- a/docs/notebooks/array_glyph/array_glyph_examples.ipynb +++ b/docs/notebooks/array_glyph/array_glyph_examples.ipynb @@ -17,23 +17,7 @@ "id": "1", "metadata": {}, "outputs": [], - "source": [ - "import os\n", - "\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "\n", - "from cleopatra.config import Config\n", - "\n", - "Config.set_matplotlib_backend()\n", - "from cleopatra.glyphs.base.animation import embed_gif\n", - "from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph, ColorBar, PointOverlay\n", - "\n", - "# Set the random seed for reproducibility\n", - "np.random.seed(42)\n", - "from cleopatra.styling.scaling import ColorScaling\n", - "from cleopatra.styling.params import CellValues, DataStyle\n" - ] + "source": "import os\n\nimport matplotlib.pyplot as plt\nimport numpy as np\n\nfrom cleopatra.config import Config\n\nConfig.set_matplotlib_backend()\nfrom cleopatra.glyphs.base.animation import embed_gif\nfrom cleopatra.glyphs.gridded.array_glyph import ArrayGlyph, ColorBar, PointOverlay, RgbBands\n\n# Set the random seed for reproducibility\nnp.random.seed(42)\nfrom cleopatra.styling.scaling import ColorScaling\nfrom cleopatra.styling.params import CellValues, DataStyle" }, { "cell_type": "markdown", @@ -459,9 +443,7 @@ "cell_type": "markdown", "id": "37", "metadata": {}, - "source": [ - "### 7.1 Preparing an Array with Cutoff Values" - ] + "source": "### 7.1 RGB percentile stretch\n\nRGB images are composited with an `RgbBands` object passed as `rgb_bands=`: the band `indices` to pull from a\nband-first array, plus a stretch. A `percentile` stretch enhances contrast by clipping the histogram tails\n(here the 2nd/98th percentiles)." }, { "cell_type": "code", @@ -469,27 +451,13 @@ "id": "38", "metadata": {}, "outputs": [], - "source": [ - "# Create an array with a wide range of values\n", - "wide_range_array = np.random.exponential(scale=2.0, size=(15, 15))\n", - "\n", - "# Initialize the ArrayGlyph with cutoff values\n", - "array_glyph_cutoff = ArrayGlyph(\n", - " wide_range_array,\n", - " cutoff=[1.0, 5.0], # Set minimum and maximum values\n", - ")\n", - "\n", - "# Plot the array\n", - "fig, ax = array_glyph_cutoff.plot(title=\"Array with Cutoff Values\", figsize=(8, 6))" - ] + "source": "# RGB compositing is configured with an RgbBands object (band indices + stretch).\n# Build a synthetic band-first (3, H, W) image.\nrgb_stack = np.random.default_rng(0).integers(0, 10000, size=(3, 40, 40)).astype(float)\n\n# Percentile stretch: clip the 2nd/98th percentile tails and rescale to [0, 1].\narray_glyph_percentile = ArrayGlyph(\n rgb_stack, rgb_bands=RgbBands([0, 1, 2], percentile=2)\n)\nfig, ax = array_glyph_percentile.plot(\n title=\"RGB with percentile stretch\", figsize=(8, 6)\n)" }, { "cell_type": "markdown", "id": "39", "metadata": {}, - "source": [ - "### 7.2 Preparing an Array with Percentile Cutoff" - ] + "source": "### 7.2 RGB surface-reflectance normalisation (with per-band cutoff)\n\n`surface_reflectance` scales raw satellite counts into `[0, 1]` (e.g. `10000` for Sentinel-2). An optional\n`cutoff` then clips each band to a fraction of that range and rescales it to `[0, 1]` for extra contrast, one\nvalue per band." }, { "cell_type": "code", @@ -497,18 +465,7 @@ "id": "40", "metadata": {}, "outputs": [], - "source": [ - "# Initialize the ArrayGlyph with percentile cutoff\n", - "array_glyph_percentile = ArrayGlyph(\n", - " wide_range_array,\n", - " percentile=2, # Exclude values below 2nd percentile and above 98th percentile\n", - ")\n", - "\n", - "# Plot the array\n", - "fig, ax = array_glyph_percentile.plot(\n", - " title=\"Array with Percentile Cutoff\", figsize=(8, 6)\n", - ")" - ] + "source": "# Surface-reflectance normalisation with an optional per-band cutoff.\narray_glyph_cutoff = ArrayGlyph(\n rgb_stack,\n rgb_bands=RgbBands([0, 1, 2], surface_reflectance=10000, cutoff=[0.3, 0.3, 0.3]),\n)\nfig, ax = array_glyph_cutoff.plot(\n title=\"RGB with surface-reflectance + cutoff\", figsize=(8, 6)\n)" }, { "cell_type": "markdown", @@ -783,4 +740,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file diff --git a/examples/array_plot_examples.py b/examples/array_plot_examples.py index b95e3e64..c19a52d2 100644 --- a/examples/array_plot_examples.py +++ b/examples/array_plot_examples.py @@ -6,7 +6,7 @@ Config.set_matplotlib_backend() import matplotlib.pyplot as plt -from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph +from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph, RgbBands # from matplotlib.transforms import blended_transform_factory # %% create the glyph from a masked array @@ -24,7 +24,7 @@ plt.show() # %% # plt.ioff() -array = ArrayGlyph(arr, rgb=[3, 2, 1], cutoff=[0.3, 0.3, 0.3]) +array = ArrayGlyph(arr, rgb_bands=RgbBands([3, 2, 1])) # %% arr = np.load("tests/data/arr.npy") exclude_value = arr[0, 0] diff --git a/examples/plot_rgb.py b/examples/plot_rgb.py index 32b2db9d..9d69eced 100644 --- a/examples/plot_rgb.py +++ b/examples/plot_rgb.py @@ -4,23 +4,24 @@ import numpy as np -from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph +from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph, RgbBands sentinel_2 = np.load("tests/data/s2a.npy") extent = [34.626902783650785, 34.654007151597256, 31.82337186561403, 31.8504762335605] # %% process the channels by excluding the top and lowest 1% of the values. -array = ArrayGlyph(sentinel_2, rgb=[3, 2, 1], percentile=1, extent=extent) +array = ArrayGlyph(sentinel_2, rgb_bands=RgbBands([3, 2, 1], percentile=1), extent=extent) fig, ax = array.plot() # %% process the channels by the surface reflectance of the sentinel data, -array = ArrayGlyph(sentinel_2, rgb=[3, 2, 1], surface_reflectance=10000) +array = ArrayGlyph(sentinel_2, rgb_bands=RgbBands([3, 2, 1], surface_reflectance=10000)) array.plot() # %% array = ArrayGlyph( - sentinel_2, rgb=[3, 2, 1], surface_reflectance=10000, cutoff=[0.3, 0.3, 0.3] + sentinel_2, + rgb_bands=RgbBands([3, 2, 1], surface_reflectance=10000, cutoff=[0.3, 0.3, 0.3]), ) array.plot() # %% sentinel_2 = np.load("tests/data/gaza-20231002.npy") -array = ArrayGlyph(sentinel_2, rgb=[0, 1, 2]) +array = ArrayGlyph(sentinel_2, rgb_bands=RgbBands([0, 1, 2])) array.plot() diff --git a/src/cleopatra/glyphs/base/glyph.py b/src/cleopatra/glyphs/base/glyph.py index 02fa0caa..ef1d2474 100644 --- a/src/cleopatra/glyphs/base/glyph.py +++ b/src/cleopatra/glyphs/base/glyph.py @@ -8,7 +8,6 @@ from __future__ import annotations import inspect -import math import os import warnings from collections.abc import Iterator @@ -26,11 +25,16 @@ from matplotlib.legend import Legend from matplotlib.patches import Rectangle -from cleopatra.glyphs.base.animation import SUPPORTED_VIDEO_FORMAT # noqa: F401 (re-export) +from cleopatra.glyphs.base.animation import ( + SUPPORTED_VIDEO_FORMAT, # noqa: F401 (re-export) +) from cleopatra.glyphs.base.animation import save_animation as _save_animation from cleopatra.styling.colors import resolve_colormap -from cleopatra.styling.scaling import MAX_DISCRETE_LEVELS # noqa: F401 (re-export) -from cleopatra.styling.scaling import ColorScaling, levels_to_bounds +from cleopatra.styling.scaling import ( + MAX_DISCRETE_LEVELS, # noqa: F401 (re-export) + ColorScaling, + levels_to_bounds, +) from cleopatra.styling.styles import DEFAULT_OPTIONS as STYLE_DEFAULTS from cleopatra.styling.styles import ( categorize, @@ -594,6 +598,33 @@ def _merge_group_params(self, *groups: Any) -> None: if key in self.default_options: self.default_options[key] = val + def _snapshot_group_options(self, *groups: Any) -> dict: + """Snapshot the current value of every option key `groups` will touch. + + Records the pre-merge value of each `default_options` key any of the + given group objects will write (via `to_options()`), so a failed merge + (e.g. an invalid `style` validated afterwards) can restore the WHOLE + set -- not just the key that failed -- keeping a co-passed + `color=`/`contour=`/`cells=` from leaking into a later plain `plot()` + on a sticky-options glyph. + + Args: + *groups: The grouped parameter objects about to be merged (each a + `to_options()`-bearing object, or `None` to skip). + + Returns: + dict: `{key: current value}` for every key the groups will touch + that exists in `default_options`. + """ + snapshot: dict = {} + for group in groups: + if group is None: + continue + for key in group.to_options(): + if key in self.default_options and key not in snapshot: + snapshot[key] = self.default_options[key] + return snapshot + @contextmanager def _rollback_options_on_error(self) -> Iterator[None]: """Restore `default_options` if the wrapped render body raises. @@ -1700,22 +1731,6 @@ def adjust_ticks( else: self.ax.get_yaxis().set_visible(visible) - @staticmethod - def _plot_point_values( - ax, point_table: np.ndarray, point_label_color, point_label_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=point_label_color, - fontsize=point_label_size, - ) - return list(map(write_points, point_table)) - def save_animation(self, path: str | os.PathLike, fps: int = 2, **kwargs) -> None: """Save this glyph's animation (`self.anim`) to a file. diff --git a/src/cleopatra/glyphs/gridded/array_glyph.py b/src/cleopatra/glyphs/gridded/array_glyph.py index ca017edd..3cc836a7 100644 --- a/src/cleopatra/glyphs/gridded/array_glyph.py +++ b/src/cleopatra/glyphs/gridded/array_glyph.py @@ -176,25 +176,6 @@ def _reject_loose_alpha(kwargs: dict) -> None: _COORD_DTYPE_MISMATCH = "coord arrays must be numeric (integer or float)" -class _Unset: - """Sentinel type for "the caller did not pass this explicit parameter". - - A plain `object()` sentinel would work too, but this gives `help()` / - IDE signature tooltips a readable `` instead of - `` for the option default that uses it (the - `hillshade` key resolved inside `ArrayGlyph.apply_style`). - """ - - def __repr__(self) -> str: - return "" - - -#: Sentinel distinguishing "the `hillshade` key was not passed" from -#: "`hillshade` was passed as `None`" when it is popped from `**kwargs` -#: (see `ArrayGlyph.apply_style`), which a plain `.get`/default check cannot. -_UNSET = _Unset() - - #: Static typing for the loose **kwargs `plot`/`animate` still accept -- #: purely a typing aid (see PEP 692 `Unpack`): with `from __future__ import #: annotations` the `**kwargs: Unpack[...]` annotations below are never @@ -392,6 +373,56 @@ def __init__( self.label_color = label_color self.label_size = label_size + def draw(self, ax) -> tuple: + """Draw this overlay's markers and per-point value labels on `ax`. + + Owns the scatter-plus-value-label drawing that `ArrayGlyph.plot` and + `.animate` share, reading only this overlay's own fields. The returned + row/column arrays let `animate` reuse the same coordinates for its + per-frame `set_offsets` updates without re-deriving them. + + Args: + ax: The matplotlib axes to draw on. + + Returns: + tuple: `(row, col, scatter, labels)` -- the point row and column + index arrays, the marker `PathCollection`, and the list of + per-point value-label `Text` artists (empty for no points). + + Examples: + - Draw two points and read back the value labels: + ```python + >>> import matplotlib + >>> matplotlib.use("Agg") + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> from cleopatra.glyphs.gridded.array_glyph import PointOverlay + >>> fig, ax = plt.subplots() + >>> overlay = PointOverlay(np.array([[5.0, 0, 0], [9.0, 1, 1]])) + >>> row, col, scatter, labels = overlay.draw(ax) + >>> len(labels) + 2 + >>> plt.close(fig) + + ``` + """ + row = self.points[:, 1] + col = self.points[:, 2] + scatter = ax.scatter(col, row, color=self.color, s=self.size) + labels = [ + ax.text( + point[2], + point[1], + point[0], + ha="center", + va="center", + color=self.label_color, + fontsize=self.label_size, + ) + for point in self.points + ] + return row, col, scatter, labels + class FrameLabel: """Styling for the per-frame time label `ArrayGlyph.animate` draws. @@ -453,6 +484,73 @@ def __init__( self.color = color self.size = size + def resolve_location(self) -> tuple[list[float], bool]: + """Resolve the label anchor and whether it is the auto default. + + Returns: + tuple: `(location, is_default)` -- the `[x, y]` anchor and a flag + that is `True` when `location` was unset (the top-left + axes-fraction default), which drives the transform and vertical + alignment in `draw`. + + Examples: + - An unset label auto-anchors top-left; an explicit one is kept: + ```python + >>> from cleopatra.glyphs.gridded.array_glyph import FrameLabel + >>> FrameLabel().resolve_location() + ([0.02, 0.95], True) + >>> FrameLabel(location=[0.3, 0.4]).resolve_location() + ([0.3, 0.4], False) + + ``` + """ + if self.location is None: + return [0.02, 0.95], True + return self.location, False + + def draw(self, ax, default_size: float): + """Draw the (blank) per-frame label text artist on `ax`. + + Owns the placement / transform / alignment logic derived from this + label's fields; the caller sets the text per frame on the returned + artist. The auto default anchors in axes-fraction coordinates + (top-left, `va="top"`); an explicit `location` uses data coordinates + (`va="baseline"`). + + Args: + ax: The matplotlib axes to draw on. + default_size: Font size used when this label's own `size` is unset + (the glyph passes its `cbar_label_size`). + + Returns: + matplotlib.text.Text: The created label artist (initially blank). + + Examples: + - Draw a label with its own size and read it back: + ```python + >>> import matplotlib + >>> matplotlib.use("Agg") + >>> import matplotlib.pyplot as plt + >>> from cleopatra.glyphs.gridded.array_glyph import FrameLabel + >>> fig, ax = plt.subplots() + >>> text = FrameLabel(size=9).draw(ax, default_size=12) + >>> text.get_fontsize() + 9.0 + >>> plt.close(fig) + + ``` + """ + location, is_default = self.resolve_location() + return ax.text( + location[0], + location[1], + " ", + fontsize=self.size if self.size is not None else default_size, + color=self.color, + transform=ax.transAxes if is_default else ax.transData, + va="top" if is_default else "baseline", + ) + class PanelLabels: """Per-panel title labels for the axes of an `ArrayGlyph.facet` grid. @@ -524,6 +622,258 @@ def __init__( self.col = col self.row = row + def validate(self, n_col: int, n_row: int | None = None) -> None: + """Check the label sequences match the facet axis sizes. + + Args: + n_col: Size of the column-facet axis. + n_row: Size of the row-facet axis, or `None` for a col-only facet. + + Raises: + ValueError: If `col` (or `row`) is set and its length does not + match the corresponding axis size. + + Examples: + - Matching lengths pass; a mismatch is rejected: + ```python + >>> from cleopatra.glyphs.gridded.array_glyph import PanelLabels + >>> PanelLabels(col=["a", "b"]).validate(2) + >>> PanelLabels(col=["a", "b"]).validate(3) + Traceback (most recent call last): + ... + ValueError: `labels.col` length 2 does not match the column axis size 3. + + ``` + """ + if self.col is not None and len(self.col) != n_col: + raise ValueError( + f"`labels.col` length {len(self.col)} does not match " + f"the column axis size {n_col}." + ) + if n_row is not None and self.row is not None and len(self.row) != n_row: + raise ValueError( + f"`labels.row` length {len(self.row)} does not match " + f"the row axis size {n_row}." + ) + + def label_for(self, axis: Literal["col", "row"], index: int) -> Any: + """Return the display label for a facet panel along `axis`. + + Falls back to the integer `index` when that axis has no labels -- the + field-only half of a panel's title. + + Args: + axis: Which facet axis, `"col"` or `"row"`. + index: Zero-based slice index of the panel along that axis. + + Returns: + The configured label at `index`, or `index` itself when that axis + has no labels. + + Examples: + - A configured label vs the integer-index fallback: + ```python + >>> from cleopatra.glyphs.gridded.array_glyph import PanelLabels + >>> PanelLabels(col=["Jan", "Feb"]).label_for("col", 1) + 'Feb' + >>> PanelLabels().label_for("col", 2) + 2 + + ``` + """ + coords = self.col if axis == "col" else self.row + return coords[index] if coords is not None else index + + def panel_title( + self, + col_dim: str, + col_idx: int, + row_dim: str | None = None, + row_idx: int | None = None, + ) -> tuple[str, dict]: + """Build a panel's title string and `name_dict` from the facet indices. + + Args: + col_dim: Name of the column dimension (the `col` argument to + `facet`). + col_idx: Zero-based column-slice index of the panel. + row_dim: Name of the row dimension, or `None` for a col-only + (3-D) facet. + row_idx: Zero-based row-slice index, or `None` for a col-only + facet. + + Returns: + tuple: `(title, name_dict)` -- the `"dim=label"` title and the + `{dim_name: label}` mapping (both axes when `row_dim` is set). + + Examples: + - A two-axis panel title and its coordinate mapping: + ```python + >>> from cleopatra.glyphs.gridded.array_glyph import PanelLabels + >>> labels = PanelLabels(col=["Jan"], row=["North"]) + >>> labels.panel_title("month", 0, "region", 0) + ('month=Jan, region=North', {'month': 'Jan', 'region': 'North'}) + + ``` + """ + col_label = self.label_for("col", col_idx) + name_dict: dict[str, Any] = {col_dim: col_label} + if row_dim is not None: + row_label = self.label_for("row", cast(int, row_idx)) + name_dict[row_dim] = row_label + title = f"{col_dim}={col_label}, {row_dim}={row_label}" + else: + title = f"{col_dim}={col_label}" + return title, name_dict + + +class RgbBands: + """Band selection and stretch for an RGB `ArrayGlyph`. + + Bundles the four RGB data-preparation parameters -- the band `indices` + and the mutually-exclusive stretch controls (`surface_reflectance`, + `cutoff`, `percentile`) -- that `ArrayGlyph.__init__` previously accepted + as four separate keywords. Pass an instance as + `ArrayGlyph(array, rgb_bands=...)`; it is only meaningful in RGB mode (the + plain single-band path takes no `rgb_bands`). + + Attributes: + indices: The `[r, g, b]` (or 4-band `[r, g, b, a]`) indices to pull + from the input array's first (band) axis. + surface_reflectance: Reflectance scale to normalise by (e.g. `10000` + for Sentinel-2, `255` for 8-bit imagery). `None` (default) skips + reflectance normalisation. + cutoff: Per-band clip cutoffs applied on the reflectance path, one + value per band. `None` (default) applies none. + percentile: Percentile for contrast-stretching the histogram; takes + precedence over `surface_reflectance` when set. `None` (default) + skips it. + + Examples: + - Band selection with a percentile stretch: + ```python + >>> import numpy as np + >>> from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph, RgbBands + >>> arr = np.random.default_rng(0).integers(0, 10000, size=(3, 8, 8)).astype(float) + >>> glyph = ArrayGlyph(arr, rgb_bands=RgbBands([0, 1, 2], percentile=2)) + >>> glyph.rgb + True + + ``` + """ + + def __init__( + self, + indices: list[int], + *, + surface_reflectance: int | None = None, + cutoff: list | None = None, + percentile: int | None = None, + ) -> None: + """Initialise an `RgbBands`. + + Args: + indices: The `[r, g, b]` band indices in the input array. + surface_reflectance: Reflectance scale to normalise by, or `None`. + cutoff: Per-band clip cutoffs, or `None`. + percentile: Percentile stretch (wins over `surface_reflectance`), + or `None`. + """ + self.indices = indices + self.surface_reflectance = surface_reflectance + self.cutoff = cutoff + self.percentile = percentile + + def validate(self, array: np.ndarray) -> None: + """Check the input array has enough bands for RGB compositing. + + Args: + array: The band-first input array to be composited. + + Raises: + ValueError: If the array has fewer than 3 bands on its first axis. + + Examples: + - A 3-band array validates; a 2-band array is rejected: + ```python + >>> import numpy as np + >>> from cleopatra.glyphs.gridded.array_glyph import RgbBands + >>> RgbBands([0, 1, 2]).validate(np.zeros((3, 4, 4))) + >>> RgbBands([0, 1, 2]).validate(np.zeros((2, 4, 4))) + Traceback (most recent call last): + ... + ValueError: RgbBands needs an array with at least 3 bands, got 2. + + ``` + """ + if array.shape[0] < 3: + raise ValueError( + f"RgbBands needs an array with at least 3 bands, " + f"got {array.shape[0]}." + ) + + def prepare(self, array: np.ndarray) -> np.ndarray: + """Composite and stretch `array` into a displayable RGB image. + + Selects `indices` from the band axis and moves bands last, then applies + the stretch: `percentile` (contrast stretch) wins, else + `surface_reflectance` (with optional `cutoff`), else the bands are just + reordered. + + Args: + array: The band-first input array. + + Returns: + np.ndarray: An `(H, W, 3)` array; normalised to `[0, 1]` when a + stretch was applied. + + Raises: + ValueError: If `indices` is `None` (no bands to select). + + Examples: + - Select and reorder three bands into a band-last image: + ```python + >>> import numpy as np + >>> from cleopatra.glyphs.gridded.array_glyph import RgbBands + >>> arr = np.arange(12, dtype=float).reshape(3, 2, 2) + >>> out = RgbBands([2, 1, 0]).prepare(arr) + >>> out.shape + (2, 2, 3) + >>> bool((out[..., 0] == arr[2]).all()) + True + + ``` + """ + if self.indices is None: + raise ValueError( + "RgbBands.indices must be the [r, g, b] band indices, got None." + ) + array = array[self.indices].transpose(1, 2, 0) + if self.percentile is not None: + return ArrayGlyph.scale_percentile(array, percentile=self.percentile) + if self.surface_reflectance is not None: + return self._apply_surface_reflectance(array) + return array + + def _apply_surface_reflectance(self, array: np.ndarray) -> np.ndarray: + """Normalise by `surface_reflectance`, then apply the optional `cutoff`. + + With a `cutoff`, each band's normalised data is clipped to + `[0, cutoff[band]]` and rescaled back to `[0, 1]` (a per-band contrast + stretch), one cutoff value per band. + + Args: + array: The `(H, W, 3)` band-last array to normalise. + + Returns: + np.ndarray: The normalised array, clipped to `[0, 1]`. + """ + array = np.clip(array / self.surface_reflectance, 0, 1) + if self.cutoff is not None: + for band, limit in enumerate(self.cutoff): + array[..., band] = np.clip(array[..., band], 0, limit) / limit + return array + class FacetGrid: """Result object for a multi-subplot facet plot. @@ -668,8 +1018,9 @@ class ArrayGlyph(GeoMixin, Glyph): ``` - Create an RGB plot from a 3D array: ```python + >>> from cleopatra.glyphs.gridded.array_glyph import RgbBands >>> rgb_array = np.random.randint(0, 255, size=(3, 10, 10)) - >>> rgb_glyph = ArrayGlyph(rgb_array, rgb=[0, 1, 2]) + >>> rgb_glyph = ArrayGlyph(rgb_array, rgb_bands=RgbBands([0, 1, 2])) >>> fig, ax = rgb_glyph.plot() ``` @@ -692,12 +1043,9 @@ def __init__( exclude_value: float | list = np.nan, extent: list | None = None, coords: tuple[np.ndarray, np.ndarray] | list[np.ndarray] | None = None, - rgb: list[int] | None = None, - surface_reflectance: int | None = None, - cutoff: list | None = None, + rgb_bands: RgbBands | None = None, ax: Axes | None = None, fig: Figure | None = None, - percentile: int | None = None, **kwargs, ): """Initialize the ArrayGlyph object with an array and optional parameters. @@ -716,14 +1064,12 @@ def __init__( matching the last two axes of `array`. When set, `kind="auto"` routes to `pcolormesh` instead of `imshow`. Mutually exclusive with `extent`. - rgb: The indices of the red, green, and blue bands in the given array, by default None. - If provided, the array will be treated as an RGB image. - Can be a list of three values [r, g, b], or four values if alpha band is included [r, g, b, a]. - surface_reflectance: Surface reflectance value for normalizing satellite data, by default None. - Typically 10000 for Sentinel-2 data. - cutoff: Clip the range of pixel values for each band, by default None. - Takes only pixel values from 0 to the value of the cutoff and scales them back to between 0 and 1. - Should be a list with one value per band. + rgb_bands: An `RgbBands` bundling the band indices and stretch for + an RGB image, by default None. When given, the array is treated + as band-first and composited to RGB via `RgbBands.prepare` + (band selection plus a percentile / surface-reflectance / cutoff + stretch). Replaces the former loose `rgb`, `surface_reflectance`, + `cutoff`, and `percentile` keywords. ax: A pre-existing axes to plot on, by default None. Bound to the glyph and used by `plot`/`animate` unless `plot(ax=...)` overrides it. Passing `ax` alone is enough — its parent @@ -734,8 +1080,6 @@ def __init__( parameter — `plot` derives the figure from its axes). When `ax` is given, `fig` is optional; if both are None a new figure is created at render time. - percentile: The percentile value to be used for scaling the array values, by default None. - Used to enhance contrast by stretching the histogram. **kwargs: Additional keyword arguments for customizing the plot. Supported arguments include: figsize : tuple, optional @@ -789,7 +1133,8 @@ def __init__( Raises: ValueError: If an invalid keyword argument is provided. - ValueError: If rgb is provided but the array doesn't have enough dimensions. + ValueError: If `rgb_bands` is given but the array has fewer than + 3 bands. ValueError: If `extend` is set to a value outside `{"neither", "both", "min", "max"}`. ValueError: If both `extent` and `coords` are supplied, @@ -816,8 +1161,11 @@ def __init__( ``` Initialization with RGB bands from a 3D array: ```python + >>> from cleopatra.glyphs.gridded.array_glyph import RgbBands >>> rgb_array = np.random.randint(0, 255, size=(3, 10, 10)) - >>> rgb_glyph = ArrayGlyph(rgb_array, rgb=[0, 1, 2], surface_reflectance=255) + >>> rgb_glyph = ArrayGlyph( + ... rgb_array, rgb_bands=RgbBands([0, 1, 2], surface_reflectance=255) + ... ) >>> fig, ax = rgb_glyph.plot() ``` @@ -935,21 +1283,10 @@ def __init__( self._coords = self._validate_coords(coords, array) - if rgb is not None: + if rgb_bands is not None: self.rgb = True - if array.shape[0] < 3: - raise ValueError( - f"To plot RGB plot the given array should have only 3 arrays, given array have " - f"{array.shape[0]}" - ) - else: - array = self.prepare_array( - array, - rgb=rgb, - surface_reflectance=surface_reflectance, - cutoff=cutoff, - percentile=percentile, - ) + rgb_bands.validate(array) + array = rgb_bands.prepare(array) else: self.rgb = False @@ -1041,8 +1378,9 @@ def prepare_array( Args: array: The input array containing multiple bands. For RGB visualization, this should be a 3D array where the first dimension represents the bands. - rgb: The indices of the red, green, and blue bands in the given array, by default None. - If None, assumes the order is [3, 2, 1] (common for Sentinel-2 data). + rgb: The `[r, g, b]` indices of the bands to composite from the input + array. Provide the band indices explicitly; there is no + functional default -- a missing `rgb` does not select bands. surface_reflectance: Surface reflectance value for normalizing satellite data, by default None. Typically 10000 for Sentinel-2 data or 255 for 8-bit imagery. Used to scale values to the range [0, 1]. @@ -1099,7 +1437,7 @@ def prepare_array( Prepare an array with cutoff values: ```python >>> rgb_array = glyph.prepare_array( - ... bands, rgb=[0, 1, 2], surface_reflectance=10000, cutoff=[5000, 5000, 5000] + ... bands, rgb=[0, 1, 2], surface_reflectance=10000, cutoff=[0.3, 0.3, 0.3] ... ) >>> rgb_array.shape (100, 100, 3) @@ -1167,77 +1505,12 @@ def prepare_array( ``` """ - array = array[rgb].transpose(1, 2, 0) - - if percentile is not None: - array = self.scale_percentile(array, percentile=percentile) - elif surface_reflectance is not None: - array = self._prepare_sentinel_rgb( - array, - rgb=rgb, - surface_reflectance=surface_reflectance, - cutoff=cutoff, - ) - return array - - def _prepare_sentinel_rgb( - self, - array: np.ndarray, - rgb: list[int] | None = None, - surface_reflectance: int = 10000, - cutoff: list | None = None, - ) -> np.ndarray: - """Prepare Sentinel satellite data for RGB visualization. - - This method specifically handles Sentinel satellite imagery by normalizing the data - using the provided surface reflectance value and optional cutoff values. - - Args: - array: The input array with shape (height, width, 3) containing RGB bands. - This array should already be transposed from the original band-first format. - rgb: The indices of the red, green, and blue bands in the original array, by default None. - Used only for cutoff application. - surface_reflectance: Surface reflectance value for normalizing satellite data, by default 10000. - Sentinel-2 data typically uses 10000 as the maximum reflectance value. - Used to scale values to the range [0, 1]. - cutoff: Clip the range of pixel values for each band, by default None. - Takes only pixel values from 0 to the value of the cutoff and scales them back to between 0 and 1. - Should be a list with one value per band. - - Returns: - np.ndarray: The prepared array with shape (height, width, 3) suitable for RGB visualization. - Values are normalized to the range [0, 1]. - - Examples: - Prepare Sentinel-2 data with default surface reflectance: - ```python - >>> import numpy as np - >>> from cleopatra.glyphs.gridded.array_glyph import ArrayGlyph - >>> # Create a simulated Sentinel-2 RGB array - >>> rgb_data = np.random.randint(0, 10000, size=(100, 100, 3)) - >>> glyph = ArrayGlyph(np.zeros((1, 1))) # Dummy initialization - >>> normalized = glyph._prepare_sentinel_rgb(rgb_data) - >>> np.all((0 <= normalized) & (normalized <= 1)) - np.True_ - - ``` - Prepare Sentinel-2 data with custom cutoff values: - ```python - >>> cutoffs = [8000, 7000, 9000] - >>> normalized = glyph._prepare_sentinel_rgb(rgb_data, rgb=[0, 1, 2], cutoff=cutoffs) - >>> np.all((0 <= normalized) & (normalized <= 1)) - np.True_ - - ``` - """ - array = np.clip(array / surface_reflectance, 0, 1) - if cutoff is not None: - bands = cast(list, rgb) - array[0] = np.clip(bands[0], 0, cutoff[0]) / cutoff[0] - array[1] = np.clip(bands[1], 0, cutoff[1]) / cutoff[1] - array[2] = np.clip(bands[2], 0, cutoff[2]) / cutoff[2] - - return array + return RgbBands( + rgb, + surface_reflectance=surface_reflectance, + cutoff=cutoff, + percentile=percentile, + ).prepare(array) @staticmethod def scale_percentile(arr: np.ndarray, percentile: int = 1) -> np.ndarray: @@ -2059,12 +2332,10 @@ def apply_style(self, style: str, **kwargs: Any) -> tuple[Figure, Axes]: self._reset_axes_for_restyle() # Fold style (and an optional forwarded hillshade) into the grouped # data_style object; leaving hillshade unset keeps any sticky value. - hillshade = kwargs.pop("hillshade", _UNSET) - data_style = ( - DataStyle(style=style) - if hillshade is _UNSET - else DataStyle(style=style, hillshade=hillshade) - ) + if "hillshade" in kwargs: + data_style = DataStyle.for_apply_style(style, hillshade=kwargs.pop("hillshade")) + else: + data_style = DataStyle.for_apply_style(style) return self.plot(data_style=data_style, ax=self.ax, **kwargs) def _resolve_style_layer(self, style: str) -> str: @@ -2559,12 +2830,7 @@ def _apply_kwargs_and_colorbar( else: self._style_color_overrides.pop(key, None) self._style_wants_colorbar = colorbar is True or ( - isinstance(colorbar, ColorBar) - and ( - colorbar.location is not None - or colorbar.inside - or colorbar.orientation is not None - ) + isinstance(colorbar, ColorBar) and colorbar.specifies_placement() ) return resolved_colorbar @@ -3197,12 +3463,7 @@ def plot( # will touch, so an invalid `style` (validated below) can roll back the # WHOLE merge -- not just `style` -- and a co-passed color=/contour=/cells= # cannot leak into a later plain plot() on this (sticky-options) glyph. - pre_group_opts = {} - for grp in (color, contour, cells, data_style): - if grp is not None: - for key in grp.to_options(): - if key in self.default_options and key not in pre_group_opts: - pre_group_opts[key] = self.default_options[key] + pre_group_opts = self._snapshot_group_options(color, contour, cells, data_style) self._merge_group_params(color, contour, cells, data_style) resolved_colorbar = self._apply_kwargs_and_colorbar(colorbar, kwargs) # type: ignore[arg-type] @@ -3372,14 +3633,9 @@ def plot( ) if points is not None and supports_overlay: - row = points.points[:, 1] - col = points.points[:, 2] - optional_display["points_scatter"] = ax.scatter( - col, row, color=points.color, s=points.size - ) - optional_display["points_id"] = self._plot_point_values( - ax, points.points, points.label_color, points.label_size - ) + _, _, points_scatter, points_labels = points.draw(ax) + optional_display["points_scatter"] = points_scatter + optional_display["points_id"] = points_labels _mark_render_artists( ax, @@ -3579,8 +3835,6 @@ def facet( if col is None and row is None: raise ValueError("at least one of `col`/`row` must be given") labels = labels or PanelLabels() - col_coords = labels.col - row_coords = labels.row if extents is not None: if self.extent is not None: raise ValueError( @@ -3614,11 +3868,7 @@ def facet( else: ncols = n_col nrows = 1 - if col_coords is not None and len(col_coords) != n_col: - raise ValueError( - f"`labels.col` length {len(col_coords)} does not match " - f"the column axis size {n_col}." - ) + labels.validate(n_col) panel_indices: list[tuple[int, int | None]] = [ (i, None) for i in range(n_col) ] @@ -3634,16 +3884,7 @@ def facet( n_col, n_row = arr.shape[0], arr.shape[1] ncols = n_col nrows = n_row - if col_coords is not None and len(col_coords) != n_col: - raise ValueError( - f"`labels.col` length {len(col_coords)} does not match " - f"the column axis size {n_col}." - ) - if row_coords is not None and len(row_coords) != n_row: - raise ValueError( - f"`labels.row` length {len(row_coords)} does not match " - f"the row axis size {n_row}." - ) + labels.validate(n_col, n_row) panel_indices = [(i, j) for j in range(n_row) for i in range(n_col)] n_panels = n_col * n_row @@ -3730,15 +3971,7 @@ def facet( data_style=data_style, ) - col_label = col_coords[col_idx] if col_coords is not None else col_idx - name_dict: dict[str, Any] = {col: col_label} - if row is not None: - row_idx = cast(int, row_idx) # non-None whenever `row` is set - row_label = row_coords[row_idx] if row_coords is not None else row_idx - name_dict[row] = row_label - title = f"{col}={col_label}, {row}={row_label}" - else: - title = f"{col}={col_label}" + title, name_dict = labels.panel_title(col, col_idx, row, row_idx) ax.set_title(title) name_dicts.append(name_dict) @@ -4154,13 +4387,6 @@ def animate( """ frame_label = frame_label or FrameLabel() - frame_location = frame_label.location - label_location_is_default = frame_location is None - if label_location_is_default: - label_location = [0.02, 0.95] - else: - label_location = frame_location - self._merge_group_params(color, contour, cells, data_style) resolved_colorbar = self._apply_kwargs_and_colorbar(colorbar, kwargs) # type: ignore[arg-type] @@ -4376,12 +4602,7 @@ def _is_rgb_frame(frame: np.ndarray) -> bool: points_scatter = None points_id: list = [] if points is not None: - row = points.points[:, 1] - col = points.points[:, 2] - points_scatter = ax.scatter(col, row, color=points.color, s=points.size) - points_id = self._plot_point_values( - ax, points.points, points.label_color, points.label_size - ) + row, col, points_scatter, points_id = points.draw(ax) background_color_threshold = None if not rgb_frames: @@ -4393,19 +4614,7 @@ def _is_rgb_frame(frame: np.ndarray) -> bool: ref_for_threshold = array if data_getter is None else frame_0 background_color_threshold = im.norm(np.nanmax(ref_for_threshold)) / 2.0 - day_text = ax.text( - label_location[0], - label_location[1], - " ", - fontsize=( - frame_label.size - if frame_label.size is not None - else self.default_options["cbar_label_size"] - ), - color=frame_label.color, - transform=ax.transAxes if label_location_is_default else ax.transData, - va="top" if label_location_is_default else "baseline", - ) + day_text = frame_label.draw(ax, self.default_options["cbar_label_size"]) self._day_text = day_text def _fetch_frame(i: int) -> np.ndarray: diff --git a/src/cleopatra/glyphs/gridded/mesh_glyph.py b/src/cleopatra/glyphs/gridded/mesh_glyph.py index 83f9cd6d..78a2210e 100644 --- a/src/cleopatra/glyphs/gridded/mesh_glyph.py +++ b/src/cleopatra/glyphs/gridded/mesh_glyph.py @@ -81,12 +81,6 @@ } MESH_DEFAULT_OPTIONS = STYLE_DEFAULTS | MESH_DEFAULT_OPTIONS -#: Sentinel distinguishing "hillshade not forwarded" from an explicit -#: `hillshade=None` in `apply_style`, so an unset value keeps any sticky -#: relief shading rather than clearing it. -_UNSET_HILLSHADE = object() - - class MeshGlyph(GeoMixin, Glyph): """Visualization class for unstructured mesh data. @@ -838,12 +832,10 @@ def apply_style( self._reset_axes_for_restyle() # Fold style (and an optional forwarded hillshade) into the grouped # data_style object; leaving hillshade unset keeps any sticky value. - hillshade = kwargs.pop("hillshade", _UNSET_HILLSHADE) - data_style = ( - DataStyle(style=style) - if hillshade is _UNSET_HILLSHADE - else DataStyle(style=style, hillshade=hillshade) - ) + if "hillshade" in kwargs: + data_style = DataStyle.for_apply_style(style, hillshade=kwargs.pop("hillshade")) + else: + data_style = DataStyle.for_apply_style(style) return self.plot( data, location=location, ax=self.ax, data_style=data_style, **kwargs ) diff --git a/src/cleopatra/glyphs/stats/kde_glyph.py b/src/cleopatra/glyphs/stats/kde_glyph.py index 84e79ca0..04e8b900 100644 --- a/src/cleopatra/glyphs/stats/kde_glyph.py +++ b/src/cleopatra/glyphs/stats/kde_glyph.py @@ -52,7 +52,7 @@ resolve_single_layer_style, resolve_style_norm, ) -from cleopatra.styling.params import Contour, DataStyle +from cleopatra.styling.params import _UNSET, Contour, DataStyle, _Unset from cleopatra.styling.scaling import ColorScaling from cleopatra.styling.styles import DEFAULT_OPTIONS as STYLE_DEFAULTS @@ -78,12 +78,6 @@ } KDE_DEFAULT_OPTIONS = STYLE_DEFAULTS | KDE_DEFAULT_OPTIONS -#: Sentinel distinguishing "hillshade not passed to apply_style" from an -#: explicit `hillshade=None` (which clears sticky relief), matching the -#: `_UNSET` sentinels on ArrayGlyph / MeshGlyph. -_UNSET_HILLSHADE = object() - - class KDEGlyph(Glyph): """Visualization class for 2-D kernel-density estimates. @@ -332,7 +326,7 @@ def apply_style( self, style: str, *, - hillshade: bool | dict | None = _UNSET_HILLSHADE, # type: ignore[assignment] + hillshade: bool | dict | None | _Unset = _UNSET, add_colorbar: bool | None = None, title: str | None = None, ): @@ -368,11 +362,7 @@ def apply_style( # Only override hillshade when the caller actually passed one; an # unset value keeps any sticky relief shading, while an explicit # `None` flows through to `DataStyle(hillshade=None)` and clears it. - data_style = ( - DataStyle(style=style) - if hillshade is _UNSET_HILLSHADE - else DataStyle(style=style, hillshade=hillshade) - ) + data_style = DataStyle.for_apply_style(style, hillshade=hillshade) return self.plot( ax=self.ax, title=title, @@ -468,12 +458,7 @@ def plot( # merging, so an invalid preset rolls back the WHOLE merge -- not # just style -- so a co-passed color=/contour= cannot leak into a # later plain plot. - prev_group_opts = {} - for grp in (color, contour, data_style): - if grp is not None: - for key in grp.to_options(): - if key in self.default_options: - prev_group_opts[key] = self.default_options[key] + prev_group_opts = self._snapshot_group_options(color, contour, data_style) self._merge_group_params(color, contour, data_style) if ax is not None: diff --git a/src/cleopatra/styling/colorbar.py b/src/cleopatra/styling/colorbar.py index 9523c2d8..0e6ebad6 100644 --- a/src/cleopatra/styling/colorbar.py +++ b/src/cleopatra/styling/colorbar.py @@ -233,6 +233,164 @@ def __init__( self.label_location = label_location self.ticks_spacing = ticks_spacing + def to_options(self) -> dict: + """Map this spec's fields onto the `cbar_*` `default_options` keys. + + Mirrors the other grouped styling objects' `to_options`: the object + owns the translation from its own fields to the flat render options + `create_color_bar` reads. Placement fields are always emitted (so a + reused glyph's prior placement is overwritten); the caption / sizing / + orientation / tick-spacing fields are emitted only when set, leaving an + unset field at the existing default. + + Returns: + dict: `default_options` updates for this spec, always including + `add_colorbar=True`. + + Examples: + - Placement maps onto `cbar_*`; unset caption fields are omitted: + ```python + >>> from cleopatra.styling.colorbar import ColorBar + >>> ColorBar(location="left", inside=True).to_options()["cbar_location"] + 'left' + >>> "cbar_label" in ColorBar(location="right").to_options() + False + + ``` + """ + updates = { + "add_colorbar": True, + "cbar_location": self.location, + "cbar_inside": self.inside, + "cbar_box": self.box, + "cbar_label_color": self.label_color, + "cbar_tick_color": self.tick_color, + } + optional = { + "cbar_label": self.label, + "cbar_length": self.length, + "cbar_label_size": self.label_size, + "cbar_label_rotation": self.label_rotation, + "cbar_label_location": self.label_location, + "cbar_orientation": self.orientation, + "ticks_spacing": self.ticks_spacing, + } + updates.update({k: v for k, v in optional.items() if v is not None}) + return updates + + def specifies_placement(self) -> bool: + """Whether this spec explicitly requests a placement or orientation. + + `True` when any of `location`, `inside`, or `orientation` is set -- the + spec asks for a specific colorbar rather than leaving the default. Used + to decide whether a styled (preset) render should still draw a colorbar. + + Returns: + bool: `True` if `location`, `inside`, or `orientation` is set. + + Examples: + - A placement edge counts as specified; a bare spec does not: + ```python + >>> from cleopatra.styling.colorbar import ColorBar + >>> ColorBar(location="bottom").specifies_placement() + True + >>> ColorBar().specifies_placement() + False + + ``` + """ + return ( + self.location is not None + or self.inside + or self.orientation is not None + ) + + @classmethod + def reset_options(cls) -> dict: + """`default_options` updates for a default, sticky-clearing colorbar. + + The dict `colorbar=True` applies: it draws a default bar and resets the + resettable `cbar_*` family to `STYLE_DEFAULTS`, so a reused glyph does + not inherit a prior sticky spec's placement or caption. Distinct from + `to_options`, which maps a *specific* spec's fields and omits unset + ones; this resets the whole `cbar_*` family to the defaults. + `ticks_spacing` is deliberately excluded: it is glyph-specific + (`KDEGlyph`, for one, auto-derives it from the data range when unset), + so a single shared reset value could not restore each glyph's own + default -- it is therefore left untouched by `colorbar=True`. + + Returns: + dict: `default_options` updates for a default colorbar. + + Examples: + - The reset always enables the bar and clears the placement: + ```python + >>> from cleopatra.styling.colorbar import ColorBar + >>> opts = ColorBar.reset_options() + >>> opts["add_colorbar"] + True + >>> opts["cbar_location"] is None + True + + ``` + """ + return { + "add_colorbar": True, + "cbar_location": None, + "cbar_inside": False, + "cbar_box": None, + "cbar_label_color": None, + "cbar_tick_color": None, + "cbar_orientation": STYLE_DEFAULTS["cbar_orientation"], + "cbar_label": STYLE_DEFAULTS["cbar_label"], + "cbar_length": STYLE_DEFAULTS["cbar_length"], + "cbar_label_size": STYLE_DEFAULTS["cbar_label_size"], + "cbar_label_rotation": STYLE_DEFAULTS["cbar_label_rotation"], + "cbar_label_location": STYLE_DEFAULTS["cbar_label_location"], + } + + @classmethod + def resolve(cls, colorbar: "bool | ColorBar | None") -> dict: + """Translate a `colorbar=` argument into `default_options` updates. + + Owns the full `None` / `False` / `True` / `ColorBar` dispatch: `None` + leaves the colorbar options untouched; `False` suppresses the bar; + `True` resets to a default bar via `reset_options`; a `ColorBar` + instance maps its fields via `to_options`. + + Args: + colorbar: `None`, `False`, `True`, or a `ColorBar` instance. + + Returns: + dict: Updates to merge into `default_options` (empty for `None`). + + Raises: + TypeError: If `colorbar` is not a bool, `ColorBar`, or `None`. + + Examples: + ```python + >>> from cleopatra.styling.colorbar import ColorBar + >>> ColorBar.resolve(False) + {'add_colorbar': False} + >>> ColorBar.resolve(ColorBar(location="left", inside=True))["cbar_location"] + 'left' + >>> "cbar_label" in ColorBar.resolve(ColorBar(location="right")) + False + + ``` + """ + if colorbar is None: + return {} + if colorbar is False: + return {"add_colorbar": False} + if colorbar is True: + return cls.reset_options() + if isinstance(colorbar, cls): + return colorbar.to_options() + raise TypeError( + f"colorbar must be a bool, ColorBar, or None, got {type(colorbar).__name__}." + ) + def _swatch_text_default(box: bool | str | dict | None) -> str: """Default swatch title/value colour that stays legible over `box`. @@ -307,45 +465,4 @@ def _resolve_colorbar(colorbar: bool | ColorBar | None) -> dict: ``` """ - if colorbar is None: - return {} - if colorbar is False: - return {"add_colorbar": False} - if colorbar is True: - return { - "add_colorbar": True, - "cbar_location": None, - "cbar_inside": False, - "cbar_box": None, - "cbar_label_color": None, - "cbar_tick_color": None, - "cbar_orientation": STYLE_DEFAULTS["cbar_orientation"], - "cbar_label": STYLE_DEFAULTS["cbar_label"], - "cbar_length": STYLE_DEFAULTS["cbar_length"], - "cbar_label_size": STYLE_DEFAULTS["cbar_label_size"], - "cbar_label_rotation": STYLE_DEFAULTS["cbar_label_rotation"], - "cbar_label_location": STYLE_DEFAULTS["cbar_label_location"], - } - if isinstance(colorbar, ColorBar): - updates = { - "add_colorbar": True, - "cbar_location": colorbar.location, - "cbar_inside": colorbar.inside, - "cbar_box": colorbar.box, - "cbar_label_color": colorbar.label_color, - "cbar_tick_color": colorbar.tick_color, - } - optional = { - "cbar_label": colorbar.label, - "cbar_length": colorbar.length, - "cbar_label_size": colorbar.label_size, - "cbar_label_rotation": colorbar.label_rotation, - "cbar_label_location": colorbar.label_location, - "cbar_orientation": colorbar.orientation, - "ticks_spacing": colorbar.ticks_spacing, - } - updates.update({k: v for k, v in optional.items() if v is not None}) - return updates - raise TypeError( - f"colorbar must be a bool, ColorBar, or None, got {type(colorbar).__name__}." - ) + return ColorBar.resolve(colorbar) diff --git a/src/cleopatra/styling/params.py b/src/cleopatra/styling/params.py index 71b14e3c..b4d5c951 100644 --- a/src/cleopatra/styling/params.py +++ b/src/cleopatra/styling/params.py @@ -123,7 +123,7 @@ def to_options(self) -> dict[str, Any]: class _Unset: """Sentinel type marking a `DataStyle` field the caller did not set.""" - def __repr__(self) -> str: # pragma: no cover - cosmetic + def __repr__(self) -> str: return "" @@ -230,6 +230,45 @@ def __post_init__(self) -> None: f"numbers, got {ar!r}" ) from exc + @classmethod + def for_apply_style( + cls, + style: str | None, + hillshade: bool | dict[str, Any] | None | _Unset = _UNSET, + ) -> DataStyle: + """Build the `DataStyle` an `apply_style(...)` call forwards to `plot`. + + Folds a preset `style` and an optionally-forwarded `hillshade` into one + object: when `hillshade` is left unset (the default sentinel) it is + omitted so any sticky relief shading is kept; an explicit value (a dict, + `True`/`False`, or `None` to clear) flows through to + `DataStyle(hillshade=...)`. Centralises the sentinel-gated construction + that the `apply_style` helpers of `ArrayGlyph`, `MeshGlyph`, and + `KDEGlyph` previously each hand-rolled with their own sentinels. + + Args: + style: The `DATA_STYLES` preset name to apply (or `None` to clear). + hillshade: Relief-shading override, or the `_UNSET` sentinel + (default) to leave it unset. + + Returns: + DataStyle: `DataStyle(style=style)` when `hillshade` is unset, else + `DataStyle(style=style, hillshade=hillshade)`. + + Examples: + ```python + >>> from cleopatra.styling.params import DataStyle + >>> DataStyle.for_apply_style("dem").to_options() + {'style': 'dem'} + >>> DataStyle.for_apply_style("dem", hillshade=True).to_options() + {'style': 'dem', 'hillshade': True} + + ``` + """ + if isinstance(hillshade, _Unset): + return cls(style=style) + return cls(style=style, hillshade=hillshade) + def to_options(self) -> dict[str, Any]: """Flatten the explicitly-given fields into `default_options` keys. diff --git a/tests/test_array_glyph.py b/tests/test_array_glyph.py index 6e9e823e..6cb71aac 100644 --- a/tests/test_array_glyph.py +++ b/tests/test_array_glyph.py @@ -13,14 +13,9 @@ from PIL import Image import cleopatra.basemap.reference as refmod -from cleopatra.styling.params import CellValues, Contour, DataStyle -from cleopatra.styling.scaling import ColorScaling -from cleopatra.styling.styles import DEFAULT_OPTIONS as STYLE_DEFAULTS from cleopatra.glyphs.gridded.array_glyph import ( _COORD_DTYPE_MISMATCH, _COORD_SHAPE_MISMATCH, - _UNSET, - _Unset, AnimateKwargs, ArrayGlyph, ColorBar, @@ -29,10 +24,13 @@ PanelLabels, PlotKwargs, PointOverlay, + RgbBands, _resolve_colorbar, _swatch_text_default, - _Unset, ) +from cleopatra.styling.params import CellValues, Contour, DataStyle +from cleopatra.styling.scaling import ColorScaling +from cleopatra.styling.styles import DEFAULT_OPTIONS as STYLE_DEFAULTS class TestProperties: @@ -62,7 +60,9 @@ def test_plot_rgb(self, sentinel_2: np.ndarray): 31.8504762335605, ] array = ArrayGlyph( - sentinel_2, rgb=[3, 2, 1], cutoff=[0.3, 0.3, 0.3], extent=extent + sentinel_2, + rgb_bands=RgbBands([3, 2, 1]), + extent=extent, ) fig, ax = array.plot(title="Flow Accumulation") im = ax.get_images()[0] @@ -432,37 +432,6 @@ def test_cell_value_text_colors_split_by_threshold(self): assert to_rgba("blue") in colors, f"high cell should be blue; got {colors}" -class TestUnsetSentinel: - """`_Unset`/`_UNSET`: the sentinel distinguishing "the caller did not - pass this" from "passed as `None`" for the `hillshade` key resolved - inside `ArrayGlyph.plot`. - """ - - def test_repr_is_readable(self): - """`repr(_UNSET)` reads ``, not the default `object()` repr. - - Test scenario: - The class docstring's whole reason for existing over a plain - `object()` sentinel is a readable `help()`/IDE tooltip; a - regression here (e.g. deleting `__repr__`) would silently - fall back to ``. - """ - assert repr(_UNSET) == "", f"Unexpected repr: {repr(_UNSET)!r}" - - def test_is_singleton_identity(self): - """`_UNSET` is a single shared instance, compared with `is` not `==`. - - Test scenario: - Callers test `value is _UNSET`; a second `_Unset()` instance - must NOT be `is _UNSET` (no `__eq__` override makes two - instances equal either), confirming the sentinel can only be - obtained by importing `_UNSET` itself. - """ - other = _Unset() - assert other is not _UNSET, "A fresh _Unset() must not be the _UNSET singleton" - assert other != _UNSET, "Two distinct _Unset instances must not compare equal" - - class TestPointOverlay: """Direct unit tests for `PointOverlay.__init__`'s defaults and attribute assignment, independent of `plot`/`animate` rendering. @@ -951,7 +920,7 @@ def test_invalid_kind_raises(self): def test_rgb_with_non_imshow_kind_raises(self): """RGB compositing is imshow-only — other kinds must raise.""" rgb_arr = np.random.randint(0, 255, size=(3, 8, 8)).astype(np.float32) - glyph = ArrayGlyph(rgb_arr, rgb=[0, 1, 2]) + glyph = ArrayGlyph(rgb_arr, rgb_bands=RgbBands([0, 1, 2])) with pytest.raises(ValueError, match="RGB"): glyph.plot(kind="pcolormesh") @@ -1299,22 +1268,59 @@ class TestPrepareArrayValidation: def test_too_few_bands_raises(self): """An RGB array with fewer than 3 bands raises `ValueError`.""" arr = np.zeros((2, 4, 4), dtype=np.float32) - with pytest.raises(ValueError, match="3 arrays"): - ArrayGlyph(arr, rgb=[0, 1]) + bands = RgbBands([0, 1]) + with pytest.raises(ValueError, match="at least 3 bands"): + ArrayGlyph(arr, rgb_bands=bands) def test_prepare_array_with_cutoff_only(self): - """`cutoff` is applied via the surface-reflectance branch.""" - arr = ( - np.random.default_rng(0) - .integers(0, 10000, size=(3, 5, 5)) - .astype(np.float32) + """`cutoff` clips + rescales each band via the surface-reflectance branch. + + Uses a fractional `cutoff` (of the normalised range) and pins the exact + per-band stretched output, so the assertion is meaningful rather than a + vacuous `[0, 1]` range check. + """ + arr = np.array( + [ + [[0.0, 3000.0]], + [[1500.0, 6000.0]], + [[9000.0, 10000.0]], + ], + dtype=float, ) glyph = ArrayGlyph(np.zeros((1, 1))) result = glyph.prepare_array( - arr, rgb=[0, 1, 2], surface_reflectance=10000, cutoff=[5000, 5000, 5000] + arr, rgb=[0, 1, 2], surface_reflectance=10000, cutoff=[0.3, 0.3, 0.3] ) - assert result.shape == (5, 5, 3) - assert np.all((0.0 <= result) & (result <= 1.0)) + expected = np.array([[[0.0, 0.5, 1.0], [1.0, 1.0, 1.0]]]) + np.testing.assert_allclose(result, expected) + + def test_cutoff_stretches_each_band_by_its_limit(self): + """`cutoff` clips + rescales each band's *data* (not the band indices). + + Regression guard: the previous code indexed `array[0]` (the first row + of the band-last array) and used the integer band index rather than the + band's pixel data, so `cutoff` never actually stretched the bands. Here + each band is clipped to `[0, cutoff[band]]` and rescaled to `[0, 1]`. + """ + # Band-first (3, 1, 2); values chosen so each band spans a different + # fraction of the reflectance range after normalisation. + arr = np.array( + [ + [[0.0, 5000.0]], # band 0 + [[2500.0, 10000.0]], # band 1 + [[10000.0, 0.0]], # band 2 + ], + dtype=float, + ) + out = RgbBands( + [0, 1, 2], surface_reflectance=10000, cutoff=[0.5, 0.5, 0.5] + ).prepare(arr) + # After /10000 then clip-to-0.5-and-rescale, per band (last axis): + # band 0: [0, 0.5] -> [0.0, 1.0] + # band 1: [0.25, 1] -> [0.5, 1.0] + # band 2: [1, 0] -> [1.0, 0.0] + expected = np.array([[[0.0, 0.5, 1.0], [1.0, 1.0, 0.0]]]) + np.testing.assert_allclose(out, expected) def test_prepare_array_no_normalisation(self): """No percentile and no surface_reflectance -> only reorder bands.""" @@ -1324,8 +1330,8 @@ def test_prepare_array_no_normalisation(self): assert result.shape == (3, 3, 3) np.testing.assert_array_equal(result[..., 0], arr[0]) - def test_prepare_sentinel_rgb_no_cutoff(self): - """`_prepare_sentinel_rgb` returns clipped data with no cutoff path.""" + def test_surface_reflectance_no_cutoff(self): + """The surface-reflectance path returns clipped data with no cutoff.""" arr = ( np.random.default_rng(0) .integers(0, 10000, size=(3, 5, 5)) @@ -1646,7 +1652,7 @@ def test_repeated_plot_with_points_does_not_stack_overlay(self): Regression: only the colorbar/image were tracked by the shared-axes cleanup; the point-overlay scatter (`ax.scatter`) and per-point value labels - (`_plot_point_values`) were not, so both doubled on a + (`PointOverlay.draw`) were not, so both doubled on a second call. """ arr = np.arange(25.0).reshape(5, 5) @@ -2384,7 +2390,7 @@ def test_rgb_arr_renders_without_norm(self): rgb_arr = ( np.random.default_rng(0).integers(0, 255, size=(3, 8, 8)).astype(np.float32) ) - glyph = ArrayGlyph(rgb_arr, rgb=[0, 1, 2]) + glyph = ArrayGlyph(rgb_arr, rgb_bands=RgbBands([0, 1, 2])) fig, ax = glyph.plot() assert isinstance(fig, Figure) # No colorbar is created on the RGB path; `cbar` stays None. @@ -4357,7 +4363,7 @@ def test_im_set_for_rgb(self): """ from matplotlib.image import AxesImage - glyph = ArrayGlyph(self._rgb_arr(), rgb=[0, 1, 2]) + glyph = ArrayGlyph(self._rgb_arr(), rgb_bands=RgbBands([0, 1, 2])) fig, ax = glyph.plot(kind="imshow") try: assert isinstance(glyph.im, AxesImage), ( @@ -4520,7 +4526,7 @@ def test_rgb_never_draws_colorbar(self, add_colorbar): RGB has no scalar mapping, so `self.cbar` stays None regardless of the toggle, while `self.im` is always populated. """ - glyph = ArrayGlyph(self._rgb_arr(), rgb=[0, 1, 2]) + glyph = ArrayGlyph(self._rgb_arr(), rgb_bands=RgbBands([0, 1, 2])) fig, ax = glyph.plot(kind="imshow", add_colorbar=add_colorbar) try: assert glyph.cbar is None, "RGB path must never create a colorbar" @@ -5128,7 +5134,7 @@ def test_rgb_with_style_warns(self): """A `style` on an RGB array is ignored with a warning, not silently.""" rgb = np.random.default_rng(6).random((3, 8, 8)) with pytest.warns(UserWarning, match="do not apply to RGB"): - ArrayGlyph(rgb, rgb=[0, 1, 2]).plot(data_style=DataStyle(style="flow_accumulation")) + ArrayGlyph(rgb, rgb_bands=RgbBands([0, 1, 2])).plot(data_style=DataStyle(style="flow_accumulation")) plt.close("all") def test_plot_style_with_hillshade_composes(self): @@ -7068,3 +7074,211 @@ def test_location_with_conflicting_orientation_does_not_raise(self): ) assert g.cbar is not None, "a colorbar should still be drawn" plt.close("all") + + +class TestRgbBands: + """Direct unit tests for the `RgbBands` band-selection + stretch object.""" + + def test_validate_raises_for_too_few_bands(self): + """`validate` rejects an array with fewer than 3 bands. + + Test scenario: + A `(2, H, W)` array raises `ValueError` naming the 3-band need. + """ + bands = RgbBands([0, 1, 2]) + arr = np.zeros((2, 4, 4)) + with pytest.raises(ValueError, match="at least 3 bands"): + bands.validate(arr) + + def test_validate_passes_for_three_bands(self): + """`validate` accepts an array with at least 3 bands. + + Test scenario: + A `(3, H, W)` array validates without raising. + """ + RgbBands([0, 1, 2]).validate(np.zeros((3, 4, 4))) + + def test_prepare_raises_when_indices_none(self): + """`prepare` fails loudly rather than producing garbage for `indices=None`. + + Test scenario: + `RgbBands(None).prepare(arr)` raises `ValueError` instead of + `array[None]` (a spurious new axis). + """ + bands = RgbBands(None) + arr = np.zeros((3, 4, 4)) + with pytest.raises(ValueError, match="indices must be"): + bands.prepare(arr) + + def test_prepare_reorders_bands_without_stretch(self): + """With no stretch, `prepare` only selects + reorders the bands. + + Test scenario: + `indices=[2, 1, 0]` puts band 2 first in the band-last output and + applies no normalisation. + """ + arr = np.arange(27, dtype=float).reshape(3, 3, 3) + out = RgbBands([2, 1, 0]).prepare(arr) + assert out.shape == (3, 3, 3), f"unexpected shape {out.shape}" + np.testing.assert_array_equal(out[..., 0], arr[2]) + + def test_prepare_percentile_path_maps_to_unit_range(self): + """The percentile branch contrast-stretches into `[0, 1]`. + + Test scenario: + `percentile=2` routes through `scale_percentile`, yielding a + band-last array clipped to `[0, 1]`. + """ + arr = np.random.default_rng(0).integers(0, 10000, size=(3, 6, 6)).astype(float) + out = RgbBands([0, 1, 2], percentile=2).prepare(arr) + assert out.shape == (6, 6, 3), f"unexpected shape {out.shape}" + assert np.all((0.0 <= out) & (out <= 1.0)), "percentile output out of [0, 1]" + + def test_prepare_surface_reflectance_path_maps_to_unit_range(self): + """The reflectance branch normalises into `[0, 1]`. + + Test scenario: + `surface_reflectance=10000` divides + clips into `[0, 1]`. + """ + arr = np.random.default_rng(0).integers(0, 10000, size=(3, 6, 6)).astype(float) + out = RgbBands([0, 1, 2], surface_reflectance=10000).prepare(arr) + assert out.shape == (6, 6, 3), f"unexpected shape {out.shape}" + assert np.all((0.0 <= out) & (out <= 1.0)), "reflectance output out of [0, 1]" + + +class TestFrameLabelMethods: + """Tests for `FrameLabel.resolve_location` and `FrameLabel.draw`.""" + + def test_resolve_location_default_when_unset(self): + """An unset location resolves to the top-left auto-anchor flagged default. + + Test scenario: + `FrameLabel()` yields `([0.02, 0.95], True)`. + """ + loc, is_default = FrameLabel().resolve_location() + assert loc == [0.02, 0.95], f"unexpected default location {loc}" + assert is_default is True, "unset location should be flagged default" + + def test_resolve_location_uses_explicit_location(self): + """An explicit location is returned as-is, not flagged default. + + Test scenario: + `FrameLabel(location=[0.3, 0.4])` yields `([0.3, 0.4], False)`. + """ + loc, is_default = FrameLabel(location=[0.3, 0.4]).resolve_location() + assert loc == [0.3, 0.4], f"explicit location not returned: {loc}" + assert is_default is False, "explicit location should not be flagged default" + + def test_draw_default_uses_default_size_and_top_alignment(self): + """`draw` on an unset label uses `default_size` and axes-fraction top anchor. + + Test scenario: + With no own size, the artist takes `default_size` and `va="top"`. + """ + fig, ax = plt.subplots() + try: + text = FrameLabel().draw(ax, default_size=14) + assert text.get_fontsize() == 14, f"size {text.get_fontsize()}" + assert text.get_verticalalignment() == "top", "default anchor should be va=top" + finally: + plt.close(fig) + + def test_draw_explicit_uses_own_size_color_and_baseline(self): + """`draw` with an explicit label uses its own size/colour and data-coord baseline. + + Test scenario: + An explicit size/colour/location yields those values and `va="baseline"`. + """ + fig, ax = plt.subplots() + try: + text = FrameLabel(location=[0.1, 0.2], color="white", size=9).draw( + ax, default_size=14 + ) + assert text.get_fontsize() == 9, f"size {text.get_fontsize()}" + assert text.get_color() == "white", f"color {text.get_color()}" + assert text.get_verticalalignment() == "baseline", "explicit should use va=baseline" + finally: + plt.close(fig) + + +class TestPanelLabelsMethods: + """Tests for `PanelLabels.label_for`, `panel_title`, and `validate`.""" + + def test_label_for_uses_coords_when_present(self): + """`label_for` returns the configured label at the index. + + Test scenario: + Given `col`/`row` sequences, the index maps to the label. + """ + labels = PanelLabels(col=["Jan", "Feb"], row=["A", "B"]) + assert labels.label_for("col", 1) == "Feb", "col label mismatch" + assert labels.label_for("row", 0) == "A", "row label mismatch" + + def test_label_for_falls_back_to_index(self): + """`label_for` returns the integer index when that axis has no labels. + + Test scenario: + An empty `PanelLabels` returns the index itself. + """ + labels = PanelLabels() + assert labels.label_for("col", 2) == 2, "col fallback mismatch" + assert labels.label_for("row", 3) == 3, "row fallback mismatch" + + def test_panel_title_col_only(self): + """`panel_title` builds a col-only title and name_dict. + + Test scenario: + With no row dim, only the column dim/label appear. + """ + title, name_dict = PanelLabels(col=["Jan", "Feb"]).panel_title("month", 1) + assert title == "month=Feb", f"title {title!r}" + assert name_dict == {"month": "Feb"}, f"name_dict {name_dict}" + + def test_panel_title_col_and_row(self): + """`panel_title` builds a two-axis title and name_dict. + + Test scenario: + Both dims/labels appear in the title and mapping. + """ + labels = PanelLabels(col=["Jan", "Feb"], row=["North", "South"]) + title, name_dict = labels.panel_title("month", 0, "region", 1) + assert title == "month=Jan, region=South", f"title {title!r}" + assert name_dict == {"month": "Jan", "region": "South"}, f"name_dict {name_dict}" + + def test_panel_title_index_fallback(self): + """`panel_title` uses the integer index when labels are absent. + + Test scenario: + An unset `PanelLabels` titles by index. + """ + title, name_dict = PanelLabels().panel_title("m", 2) + assert title == "m=2", f"title {title!r}" + assert name_dict == {"m": 2}, f"name_dict {name_dict}" + + def test_validate_passes_when_lengths_match(self): + """`validate` accepts label sequences matching the axis sizes. + + Test scenario: + 2 col labels + 1 row label against `(2, 1)` axes validate cleanly. + """ + PanelLabels(col=["a", "b"], row=["x"]).validate(2, 1) + + def test_validate_raises_on_col_length_mismatch(self): + """`validate` rejects a col-label count that does not match the axis. + + Test scenario: + 2 col labels against a 3-column axis raises, naming `labels.col`. + """ + labels = PanelLabels(col=["a", "b"]) + with pytest.raises(ValueError, match=r"labels\.col"): + labels.validate(3) + + def test_validate_raises_on_row_length_mismatch(self): + """`validate` rejects a row-label count that does not match the axis. + + Test scenario: + 1 row label against a 2-row axis raises, naming `labels.row`. + """ + labels = PanelLabels(col=["a", "b"], row=["x"]) + with pytest.raises(ValueError, match=r"labels\.row"): + labels.validate(2, 2) diff --git a/tests/test_colorbar_glyphs.py b/tests/test_colorbar_glyphs.py index 395f7f34..bd468d06 100644 --- a/tests/test_colorbar_glyphs.py +++ b/tests/test_colorbar_glyphs.py @@ -14,14 +14,15 @@ import numpy as np import pytest -from cleopatra.styling.colorbar import ColorBar -from cleopatra.styling.params import Classify -from cleopatra.glyphs.primitives.flow_glyph import FlowGlyph -from cleopatra.glyphs.stats.kde_glyph import KDEGlyph from cleopatra.glyphs.gridded.mesh_glyph import MeshGlyph +from cleopatra.glyphs.gridded.vector_glyph import VectorGlyph +from cleopatra.glyphs.primitives.flow_glyph import FlowGlyph from cleopatra.glyphs.primitives.polygon_glyph import PolygonGlyph from cleopatra.glyphs.primitives.scatter_glyph import ScatterGlyph -from cleopatra.glyphs.gridded.vector_glyph import VectorGlyph +from cleopatra.glyphs.stats.kde_glyph import KDEGlyph +from cleopatra.styling.colorbar import ColorBar +from cleopatra.styling.params import Classify +from cleopatra.styling.styles import DEFAULT_OPTIONS as STYLE_DEFAULTS _RNG = np.random.default_rng(1337) _FLOW_PATHS = [np.array([[0.0, 0.0], [1.0, 1.0]]), np.array([[0.0, 1.0], [1.0, 0.0]])] @@ -238,3 +239,118 @@ def test_colorbar_false_suppresses_under_categorical_scheme(): ) glyph.plot(colorbar=False, classify=Classify(scheme="categorical")) assert glyph.cbar is None, "colorbar=False should suppress the bar under a categorical scheme" + + +class TestColorBarMethods: + """Direct unit tests for `ColorBar`'s own option-building methods.""" + + def test_to_options_maps_placement_and_omits_unset(self): + """`to_options` emits placement keys but omits unset caption fields. + + Test scenario: + A spec with only placement set yields the `cbar_*` placement keys + plus `add_colorbar=True`, and no `cbar_label` (it was never set). + """ + opts = ColorBar(location="left", inside=True).to_options() + assert opts["cbar_location"] == "left", f"location not mapped: {opts}" + assert opts["cbar_inside"] is True, f"inside not mapped: {opts}" + assert opts["add_colorbar"] is True, f"add_colorbar missing: {opts}" + assert "cbar_label" not in opts, f"unset caption should be omitted: {opts}" + + def test_to_options_emits_set_caption_and_tick_fields(self): + """Set caption / sizing / tick fields map onto their `cbar_*` keys. + + Test scenario: + `label`, `length`, and `ticks_spacing` set on the spec appear in the + emitted dict. + """ + opts = ColorBar(label="Depth", length=0.8, ticks_spacing=2.0).to_options() + assert opts["cbar_label"] == "Depth", f"label not mapped: {opts}" + assert opts["cbar_length"] == 0.8, f"length not mapped: {opts}" + assert opts["ticks_spacing"] == 2.0, f"ticks_spacing not mapped: {opts}" + + def test_resolve_none_returns_empty(self): + """`resolve(None)` leaves options untouched (empty dict). + + Test scenario: + `None` is the "keep current" case, so no keys are emitted. + """ + assert ColorBar.resolve(None) == {}, "None should resolve to an empty dict" + + def test_resolve_false_suppresses(self): + """`resolve(False)` emits only `add_colorbar=False`. + + Test scenario: + `False` suppresses the colorbar and nothing else. + """ + assert ColorBar.resolve(False) == {"add_colorbar": False}, "False should suppress" + + def test_resolve_true_matches_reset_options(self): + """`resolve(True)` returns the `reset_options` default dict. + + Test scenario: + `True` and `reset_options()` must agree (both draw the default bar). + """ + assert ColorBar.resolve(True) == ColorBar.reset_options(), ( + "resolve(True) should equal reset_options()" + ) + + def test_resolve_instance_delegates_to_to_options(self): + """`resolve(spec)` delegates to the instance's `to_options`. + + Test scenario: + A `ColorBar` instance resolves to exactly its `to_options()` dict. + """ + cb = ColorBar(location="bottom") + assert ColorBar.resolve(cb) == cb.to_options(), "instance should delegate to to_options" + + def test_resolve_invalid_type_raises(self): + """`resolve` rejects a non-bool / non-`ColorBar` / non-`None` argument. + + Test scenario: + An int argument raises `TypeError` naming the accepted types. + """ + with pytest.raises(TypeError, match="bool, ColorBar, or None"): + ColorBar.resolve(123) + + def test_reset_options_resets_family_to_defaults(self): + """`reset_options` clears the resettable `cbar_*` family to defaults. + + Test scenario: + Placement keys go to `None`/`False`, and the caption/orientation keys + take their `STYLE_DEFAULTS` values. + """ + opts = ColorBar.reset_options() + assert opts["add_colorbar"] is True, f"add_colorbar missing: {opts}" + assert opts["cbar_location"] is None, f"location not reset: {opts}" + assert opts["cbar_inside"] is False, f"inside not reset: {opts}" + assert opts["cbar_orientation"] == STYLE_DEFAULTS["cbar_orientation"], ( + f"orientation not from defaults: {opts}" + ) + assert opts["cbar_label"] == STYLE_DEFAULTS["cbar_label"], ( + f"label not from defaults: {opts}" + ) + + @pytest.mark.parametrize( + "kwargs, expected", + [ + ({"location": "left"}, True), + ({"inside": True}, True), + ({"orientation": "horizontal"}, True), + ({}, False), + ({"label": "x"}, False), + ], + ) + def test_specifies_placement(self, kwargs, expected): + """`specifies_placement` is true iff location/inside/orientation is set. + + Args: + kwargs: `ColorBar` constructor arguments for the case. + expected: Whether the spec should count as requesting placement. + + Test scenario: + Any of `location`/`inside`/`orientation` yields True; a bare spec or + a caption-only spec yields False. + """ + got = ColorBar(**kwargs).specifies_placement() + assert got is expected, f"specifies_placement({kwargs}) -> {got}, expected {expected}" diff --git a/tests/test_glyph.py b/tests/test_glyph.py index e3636664..ce793a5e 100644 --- a/tests/test_glyph.py +++ b/tests/test_glyph.py @@ -25,6 +25,8 @@ _clear_prior_render_artists, _mark_render_artists, ) +from cleopatra.glyphs.gridded.array_glyph import PointOverlay +from cleopatra.styling.params import CellValues, Contour from cleopatra.styling.styles import DEFAULT_OPTIONS as STYLE_DEFAULTS from cleopatra.styling.styles import ColorScale, MidpointNormalize @@ -593,31 +595,37 @@ def test_hide_y_axis(self): plt.close(fig) -class TestPlotPointValues: - """Tests for Glyph._plot_point_values static method.""" +class TestPointOverlayDraw: + """Tests for PointOverlay.draw (per-point markers + value labels).""" def test_creates_text_per_point(self): - """Test that one text artist is created per point.""" + """Test that one value-label text artist is created per point.""" fig, ax = plt.subplots() - points = np.array([[10.0, 0, 0], [20.0, 1, 1], [30.0, 2, 2]]) - texts = Glyph._plot_point_values(ax, points, "blue", 12) + overlay = PointOverlay( + np.array([[10.0, 0, 0], [20.0, 1, 1], [30.0, 2, 2]]), + label_color="blue", + label_size=12, + ) + _, _, _, texts = overlay.draw(ax) assert len(texts) == 3, f"Expected 3 text artists, got {len(texts)}" plt.close(fig) def test_text_positions(self): - """Test that text is placed at (col, row) coordinates.""" + """Test that each value label is placed at its (col, row) coordinate.""" fig, ax = plt.subplots() - points = np.array([[99.0, 3.0, 5.0]]) - texts = Glyph._plot_point_values(ax, points, "red", 10) + overlay = PointOverlay( + np.array([[99.0, 3.0, 5.0]]), label_color="red", label_size=10 + ) + _, _, _, texts = overlay.draw(ax) pos = texts[0].get_position() assert pos == (5.0, 3.0), f"Expected position (5, 3), got {pos}" plt.close(fig) def test_empty_points_returns_empty_list(self): - """Test that empty points array returns empty list.""" + """Test that an empty points array draws no value labels.""" fig, ax = plt.subplots() - points = np.empty((0, 3)) - texts = Glyph._plot_point_values(ax, points, "red", 10) + overlay = PointOverlay(np.empty((0, 3))) + _, _, _, texts = overlay.draw(ax) assert len(texts) == 0, f"Expected 0 text artists, got {len(texts)}" plt.close(fig) @@ -1683,3 +1691,66 @@ def test_clear_propagates_unexpected_exception_types(self): _clear_prior_render_artists(ax) finally: plt.close(fig) + + +class _FakeGlyph: + """Minimal stand-in exposing only `default_options` for helper tests.""" + + def __init__(self, options): + """Store a copy of `options` as `default_options`. + + Args: + options: The flat option dict the fake glyph should expose. + """ + self.default_options = dict(options) + + +class TestSnapshotGroupOptions: + """Tests for `Glyph._snapshot_group_options`.""" + + def test_records_current_value_of_touched_keys(self): + """Snapshots the pre-merge value of each supported key the groups touch. + + Test scenario: + Two groups touching `levels` and `display_cell_value` yield a + snapshot of exactly those keys' current values. + """ + glyph = _FakeGlyph({"levels": 3, "display_cell_value": False, "other": 1}) + snap = Glyph._snapshot_group_options( + glyph, Contour(levels=9), CellValues(show=True) + ) + assert snap == {"levels": 3, "display_cell_value": False}, f"got {snap}" + + def test_skips_none_groups(self): + """A `None` group is skipped without error. + + Test scenario: + Passing `None` alongside a real group snapshots only the real one. + """ + glyph = _FakeGlyph({"levels": 3}) + snap = Glyph._snapshot_group_options(glyph, None, Contour(levels=9)) + assert snap == {"levels": 3}, f"got {snap}" + + def test_skips_keys_absent_from_default_options(self): + """Keys a group touches but the glyph does not support are ignored. + + Test scenario: + A `Contour(levels=...)` on a glyph whose options lack `levels` + snapshots nothing. + """ + glyph = _FakeGlyph({"unrelated": 1}) + snap = Glyph._snapshot_group_options(glyph, Contour(levels=9)) + assert snap == {}, f"got {snap}" + + def test_first_group_wins_on_duplicate_key(self): + """A key touched by two groups is snapshotted once (pre-merge value). + + Test scenario: + Two `Contour` objects both emit `levels`; the snapshot keeps the + single current value, not a later group's requested value. + """ + glyph = _FakeGlyph({"levels": 7}) + snap = Glyph._snapshot_group_options( + glyph, Contour(levels=1), Contour(levels=2) + ) + assert snap == {"levels": 7}, f"got {snap}" diff --git a/tests/test_params.py b/tests/test_params.py new file mode 100644 index 00000000..3e3c57ed --- /dev/null +++ b/tests/test_params.py @@ -0,0 +1,89 @@ +"""Tests for grouped rendering-parameter objects in ``cleopatra.styling.params``. + +Focused unit tests for the object-owned logic (currently the +``DataStyle.for_apply_style`` factory); the per-field ``to_options`` flattening +is covered by the module doctests. +""" + +from __future__ import annotations + +import pytest + +from cleopatra.styling.params import _UNSET, DataStyle, _Unset + + +class TestDataStyleForApplyStyle: + """Tests for ``DataStyle.for_apply_style``.""" + + def test_hillshade_unset_omits_hillshade(self): + """Leaving ``hillshade`` unset builds ``DataStyle(style=...)`` only. + + Test scenario: + The default sentinel means "not passed", so ``to_options()`` emits + just ``style`` and keeps any sticky relief shading. + """ + ds = DataStyle.for_apply_style("dem") + assert ds.to_options() == {"style": "dem"}, f"unexpected options: {ds.to_options()}" + + @pytest.mark.parametrize("hillshade", [True, False, {"azimuth": 315}, None]) + def test_hillshade_given_flows_through(self, hillshade): + """An explicit ``hillshade`` (incl. ``None``/``False``) is folded in. + + Args: + hillshade: The explicit override to forward. + + Test scenario: + Any non-sentinel value -- a dict, ``True``/``False``, or an explicit + ``None`` that clears sticky shading -- is emitted alongside ``style``. + """ + ds = DataStyle.for_apply_style("dem", hillshade=hillshade) + assert ds.to_options() == { + "style": "dem", + "hillshade": hillshade, + }, f"hillshade not forwarded: {ds.to_options()}" + + def test_explicit_unset_sentinel_behaves_as_unset(self): + """Passing ``_UNSET`` explicitly behaves like omitting ``hillshade``. + + Test scenario: + ``for_apply_style(..., hillshade=_UNSET)`` must not emit a + ``hillshade`` key. + """ + ds = DataStyle.for_apply_style("dem", hillshade=_UNSET) + assert "hillshade" not in ds.to_options(), ( + f"sentinel should not emit hillshade: {ds.to_options()}" + ) + + def test_style_none_clears_preset(self): + """``style=None`` flows through to clear a sticky preset. + + Test scenario: + ``for_apply_style(None)`` emits ``style=None`` (the clear signal). + """ + ds = DataStyle.for_apply_style(None) + assert ds.to_options() == {"style": None}, f"unexpected options: {ds.to_options()}" + + +class TestUnsetSentinel: + """Tests for the `DataStyle` "not passed" sentinel `_UNSET` / `_Unset`.""" + + def test_repr_is_readable(self): + """`repr(_UNSET)` reads `` for legible `help()` / IDE tooltips. + + Test scenario: + The sentinel exists over a plain `object()` precisely for a + readable repr; deleting `__repr__` would regress to the default + `<...params._Unset object at 0x...>`. + """ + assert repr(_UNSET) == "", f"Unexpected repr: {repr(_UNSET)!r}" + + def test_is_singleton_identity(self): + """`_UNSET` is a single shared instance, compared with `is` not `==`. + + Test scenario: + Gates test `value is _UNSET` (or `isinstance(value, _Unset)`); a + fresh `_Unset()` must not be the singleton nor compare equal to it. + """ + other = _Unset() + assert other is not _UNSET, "A fresh _Unset() must not be the _UNSET singleton" + assert other != _UNSET, "Two distinct _Unset instances must not compare equal"