From 5530aa282045802add780bfa1521269f7440dc3d Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 28 Oct 2026 12:42:49 +0000 Subject: [PATCH 1/3] moved the code to a clean branch --- .../bhom_tkinter/widgets/cmap_selector.py | 38 ++ .../widgets/drop_down_selection.py | 1 + .../bhom_tkinter/windows/__init__.py | 2 + .../windows/colour_scale_builder.py | 358 +++++++++++ .../src/python_toolkit/plot/colour_scales.py | 571 ++++++++++++++++++ .../Python/tests/test_colour_scale_builder.py | 136 +++++ .../Python/tests/test_colour_scales.py | 184 ++++++ 7 files changed, 1290 insertions(+) create mode 100644 Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py create mode 100644 Python_Engine/Python/src/python_toolkit/plot/colour_scales.py create mode 100644 Python_Engine/Python/tests/test_colour_scale_builder.py create mode 100644 Python_Engine/Python/tests/test_colour_scales.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py index 4d53594..9165a0f 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py @@ -9,6 +9,8 @@ from python_toolkit.bhom_tkinter.widgets.figure_container import FigureContainer from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions +from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.plot.colour_scales import ColourScale, ColourScaleLibrary from python_toolkit.bhom.analytics import CONSOLE_LOGGER from python_toolkit.bhom_tkinter.theming.scaling import scale_pad @@ -78,6 +80,7 @@ def __init__( default_cmap: Optional[CmapInput] = None, plot_size: tuple[int, int] = (400, 50), dropdown_position: Literal["n", "e", "s", "w"] = "n", + allow_custom: bool = False, **kwargs ) -> None: """ @@ -98,6 +101,9 @@ def __init__( dropdown_position: Position of the dropdown relative to the preview swatch. "n" = above, "s" = below, "w" = left, "e" = right. + allow_custom: When True, colour scales saved in the + `ColourScaleLibrary` are listed first, and a button opens the + `ColourScaleBuilder` to make a new one. **kwargs: Additional Frame options. """ super().__init__(parent, **kwargs) @@ -166,12 +172,44 @@ def __init__( else: current_colormaps = self._preset_colormaps(self.cmap_set_var.get()) + if allow_custom: + current_colormaps = self._saved_scale_labels(current_colormaps) + current_colormaps + self._populate_cmap_list(current_colormaps) # Resolve default_cmap: may itself be a Colormap object or colour list self._default_label = self._resolve_default_label(default_cmap, current_colormaps) self._select_default_cmap(current_colormaps) + if allow_custom: + self.new_scale_button = Button(header, text="New colour scale...", command=self._open_scale_builder) + self.new_scale_button.pack(side=tk.TOP, anchor=self._pack_anchor, fill="x", pady=(4, 0)) + + def _saved_scale_labels(self, existing: List[str]) -> List[str]: + """Register every saved colour scale as a custom entry and return their labels.""" + labels: List[str] = [] + for scale in ColourScaleLibrary().scales(): + label = self._unique_label(scale.name, existing + labels) + self._custom_cmaps[label] = scale.to_colormap() + labels.append(label) + return labels + + def _open_scale_builder(self) -> None: + """Open the colour scale builder; a saved scale is added to the list and selected.""" + # imported here: the builder window is built from these widgets + from python_toolkit.bhom_tkinter.windows.colour_scale_builder import ColourScaleBuilder + + ColourScaleBuilder(self.winfo_toplevel(), on_save=self._on_scale_saved).run() + + def _on_scale_saved(self, scale: ColourScale) -> None: + existing = list(self.cmap_combobox["values"]) + label = self._unique_label(scale.name, existing) + self._custom_cmaps[label] = scale.to_colormap() + self._populate_cmap_list([label] + existing) + self.colormap_var.set(label) + self._update_cmap_sample() + self._fire_on_change(self.get()) + def _get_all_colormaps(self) -> List[str]: """Return all registered colormap names, including reversed variants. diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py index f5afb4d..47b6083 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/drop_down_selection.py @@ -4,6 +4,7 @@ from tkinter import ttk from typing import Optional, Callable, List, Literal +from python_toolkit.bhom.logging import CONSOLE_LOGGER from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget class DropDownSelection(BHoMBaseWidget): diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py index cdd1dab..a23d2c0 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/__init__.py @@ -1,4 +1,5 @@ from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow +from .colour_scale_builder import ColourScaleBuilder from .directory_file_selector import DirectoryFileSelector from .landing_page import LandingPage from .modal_window import BHoMModalWindow @@ -8,6 +9,7 @@ __all__ = [ "BHoMBaseChildWindow", "BHoMModalWindow", + "ColourScaleBuilder", "DirectoryFileSelector", "LandingPage", "ProcessingWindow", diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py new file mode 100644 index 0000000..bf2e531 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py @@ -0,0 +1,358 @@ +"""Window for building colour scales from the data they will colour. + +Pass the data in and the window shows its distribution, suggests band thresholds, and +lets the user colour, label and save the scale into a named set of the colour scale +library (``python_toolkit.plot.colour_scales``):: + + builder = ColourScaleBuilder(parent, data=room_temperatures, units="degC") + builder.run() + scale = builder.saved_scale # None if closed without saving +""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk +from typing import Callable, Iterable, List, Optional + +import numpy as np +from matplotlib.figure import Figure + +from python_toolkit.bhom.logging import CONSOLE_LOGGER +from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow +from python_toolkit.bhom_tkinter.windows.warning_box import WarningBox +from python_toolkit.bhom_tkinter.widgets._grid_options import GridOptions +from python_toolkit.bhom_tkinter.widgets.button import Button +from python_toolkit.bhom_tkinter.widgets.check_box_selection import CheckboxSelection +from python_toolkit.bhom_tkinter.widgets.colour_picker import ColourPicker +from python_toolkit.bhom_tkinter.widgets.drop_down_selection import DropDownSelection +from python_toolkit.bhom_tkinter.widgets.figure_container import FigureContainer +from python_toolkit.bhom_tkinter.widgets.label import Label +from python_toolkit.bhom_tkinter.widgets.radio_selection import RadioSelection +from python_toolkit.bhom_tkinter.widgets.spinbox import Spinbox +from python_toolkit.bhom_tkinter.widgets.validated_entry_box import ValidatedEntryBox +from python_toolkit.plot.colour_scales import ( + DEFAULT_SET, + PALETTES, + SUGGESTION_METHODS, + ColourScale, + ColourScaleError, + ColourScaleLibrary, + DataSummary, + GradientScale, + ThresholdScale, + clean_data, + palette_colours, + palette_names, + suggest_thresholds, +) + +THRESHOLDS = "Threshold bands" +GRADIENT = "Gradient" +MAX_BANDS = 12 +ERROR_COLOUR = "#c0392b" + + +class ColourScaleBuilder(BHoMBaseChildWindow): + """Build, preview and save a ThresholdScale or GradientScale against real data.""" + + def __init__( + self, + parent=None, + data: Optional[Iterable] = None, + units: Optional[str] = None, + scale: Optional[ColourScale] = None, + library: Optional[ColourScaleLibrary] = None, + on_save: Optional[Callable[[ColourScale], None]] = None, + palette: Optional[str] = None, + **kwargs, + ): + """ + Args: + parent: Owning window (None for a standalone window). + data: Values the scale will colour; drives the histogram and suggestions. + units: Units of the data, stored with the scale. + scale: An existing scale to edit (saving replaces it). + library: Library to save into (defaults to the user's library). + on_save: Called with the saved scale. + palette: Palette or matplotlib colour map to start from (e.g. the plot's current one). + """ + self.values = clean_data(data) + self.summary = DataSummary.from_data(self.values) if self.values.size else None + self.units = units if units is not None else (scale.units if scale else None) + self.library = library if library is not None else ColourScaleLibrary() + self.editing = scale + self.on_save = on_save + self.saved_scale: Optional[ColourScale] = None + self._band_rows: List[dict] = [] + self._pending = None + self._start_palette = palette + super().__init__( + parent, + title="Colour scale", + submit_text="Save scale", + submit_command=self._save, + close_on_submit=False, + close_text="Close", + modal=parent is not None, + defer_show=True, + resizable=True, + min_width=kwargs.pop("min_width", 620), + min_height=kwargs.pop("min_height", 800), + **kwargs, + ) + self._build_contents() + self.build() + self._load(scale) + + # ── layout ───────────────────────────────────────────────────────────── + + def _build_contents(self) -> None: + frame = self.content_frame + sets = self.library.groups() or [DEFAULT_SET] + default_set = self.editing.group if self.editing else sets[0] + + self.name = ValidatedEntryBox(frame, id="name", helper_text="Name", required=False, width=30, + on_change=lambda _v: self._schedule_preview(), # re-checks, clearing old errors + build_options=GridOptions(row=0, column=0, sticky="w", padx=6, pady=3)) + self.group = DropDownSelection(frame, id="group", helper_text="Set (type a new name to start a set)", + options=sets, default=default_set, state="normal", width=28, + build_options=GridOptions(row=0, column=1, sticky="w", padx=6, pady=3)) + self.kind = RadioSelection(frame, id="kind", fields=[THRESHOLDS, GRADIENT], default=THRESHOLDS, + orient="horizontal", command=lambda _v: self._on_kind_changed(), + build_options=GridOptions(row=1, column=0, columnspan=2, sticky="w", padx=6, pady=3)) + self.data_text = Label(frame, text=self._summary_text(), + build_options=GridOptions(row=2, column=0, columnspan=2, sticky="w", padx=6)) + self.preview = FigureContainer(frame, id="preview", rigid_width=560, rigid_height=130, + build_options=GridOptions(row=3, column=0, columnspan=2, sticky="w", padx=6, pady=4)) + + suggest = ttk.Frame(frame) + suggest.grid(row=5, column=0, columnspan=2, sticky="w", padx=6, pady=3) + self.band_count = Spinbox(suggest, id="bands", helper_text="Bands", values=list(range(2, MAX_BANDS + 1)), + default=5, width=5, build_options=GridOptions(row=0, column=0, sticky="w", padx=(0, 8))) + self.method = DropDownSelection(suggest, id="method", helper_text="Breaks", options=list(SUGGESTION_METHODS), + default="nice", state="readonly", width=10, + build_options=GridOptions(row=0, column=1, sticky="w", padx=(0, 8))) + palettes = palette_names() + self.palette = DropDownSelection(suggest, id="palette", helper_text="Palette / colour map", options=palettes, + default=self._start_palette if self._start_palette in palettes else None, + state="readonly", width=28, command=lambda _v: self._recolour(), + build_options=GridOptions(row=0, column=2, sticky="w", padx=(0, 8))) + self.suggest_button = Button(suggest, text="Suggest from data", command=self._suggest, width=18, + build_options=GridOptions(row=0, column=3, sticky="sw")) + for widget in (self.band_count, self.method, self.palette, self.suggest_button): + widget.build() + if self.summary is None: + self.suggest_button.button.configure(state="disabled") + + self.bands_frame = ttk.Frame(frame) + self.bands_frame.grid(row=6, column=0, columnspan=2, sticky="w", padx=6, pady=3) + self.gradient_frame = ttk.Frame(frame) + self.gradient_frame.grid(row=6, column=0, columnspan=2, sticky="w", padx=6, pady=3) + self.reverse = CheckboxSelection(self.gradient_frame, id="reverse", fields=["Reverse"], + command=lambda _v: self._schedule_preview(), + build_options=GridOptions(row=0, column=0, sticky="w", padx=(0, 12))) + self.steps = Spinbox(self.gradient_frame, id="steps", helper_text="Steps (0 = smooth)", + values=[0] + list(range(2, 21)), default=0, width=5, + command=lambda _v: self._schedule_preview(), + build_options=GridOptions(row=0, column=1, sticky="w")) + self.reverse.build() + self.steps.build() + + self.status = Label(frame, text="", foreground=ERROR_COLOUR, wraplength=560, justify="left", + build_options=GridOptions(row=4, column=0, columnspan=2, sticky="w", padx=6)) + self.widgets = [self.name, self.group, self.kind, self.data_text, self.preview, self.status] + + def _summary_text(self) -> str: + if self.summary is None: + return "No data passed in: set the thresholds by hand." + s = self.summary + units = f" {self.units}" if self.units else "" + return (f"{s.count} values from {s.minimum:.4g} to {s.maximum:.4g}{units}; " + f"median {s.quartiles[1]:.4g}, middle half {s.quartiles[0]:.4g} to {s.quartiles[2]:.4g}") + + # ── state ────────────────────────────────────────────────────────────── + + def _load(self, scale: Optional[ColourScale]) -> None: + if isinstance(scale, GradientScale): + self.name.set(scale.name) + self.kind.set(GRADIENT) + self.steps.set(scale.bins or 0) + elif isinstance(scale, ThresholdScale): + self.name.set(scale.name) + self._set_bands(scale.thresholds, scale.colours, scale.labels) + elif self.summary is not None: + self.name.set(self._unused_name("New scale")) + self._suggest() + else: + self.name.set(self._unused_name("New scale")) + self._set_bands([1.0, 2.0], palette_colours(self.palette.get(), 3), None) + self._on_kind_changed() + + def _on_kind_changed(self) -> None: + threshold = self.kind.get() == THRESHOLDS + if threshold: + self.gradient_frame.grid_remove() + self.bands_frame.grid() + else: + self.bands_frame.grid_remove() + self.gradient_frame.grid() + self._schedule_preview() + + def _suggest(self) -> None: + try: + bands = int(self.band_count.get()) + thresholds = suggest_thresholds(self.values, bands, self.method.get()) + except (ColourScaleError, ValueError) as e: + self.status.set(str(e)) + return + self._set_bands(thresholds, palette_colours(self.palette.get(), len(thresholds) + 1), None) + + def _recolour(self) -> None: + """A new palette recolours the bands, keeping their thresholds and labels.""" + if not self._band_rows: + return + for row, colour in zip(self._band_rows, palette_colours(self.palette.get(), len(self._band_rows))): + row["colour"].set(colour) + self._schedule_preview() + + def _set_bands(self, thresholds, colours, labels) -> None: + for child in self.bands_frame.winfo_children(): + child.destroy() + self._band_rows = [] + n = len(colours) + headers = ("Colour", "Label (legend)", "From", "Up to") + for col, text in enumerate(headers): + ttk.Label(self.bands_frame, text=text, style="Caption.TLabel").grid(row=0, column=col, sticky="w", padx=4) + for i in range(n): + picker = ColourPicker(self.bands_frame, default_colour=colours[i], swatch_width=36, swatch_height=20, + command=lambda _c: self._schedule_preview()) + picker.grid(row=i + 1, column=0, sticky="nw", padx=4, pady=(6, 0)) + label = ValidatedEntryBox(self.bands_frame, required=False, width=22, + on_change=lambda _v: self._schedule_preview()) + label.grid(row=i + 1, column=1, sticky="w", padx=4) + label.set((labels[i] or "") if labels else "") + lower = ttk.Label(self.bands_frame, text="") + lower.grid(row=i + 1, column=2, sticky="nw", padx=4, pady=(6, 0)) + upper = None + if i < n - 1: + upper = ValidatedEntryBox(self.bands_frame, required=False, width=10, + on_change=lambda _v: self._schedule_preview()) + upper.grid(row=i + 1, column=3, sticky="w", padx=4) + upper.set(f"{thresholds[i]:.6g}") + else: + ttk.Label(self.bands_frame, text="and above").grid(row=i + 1, column=3, sticky="nw", padx=4, pady=(6, 0)) + self._band_rows.append({"colour": picker, "label": label, "lower": lower, "upper": upper}) + self.band_count.set(n) + self._schedule_preview() + + def current_scale(self) -> ColourScale: + """The scale described by the window right now (raises ColourScaleError if invalid).""" + name = (self.name.get() or "").strip() + group = (self.group.get() or "").strip() or DEFAULT_SET + if self.kind.get() == GRADIENT: + name = self.palette.get() + colours = PALETTES.get(name) or palette_colours(name, 9) + if self.reverse.get(): + colours = colours[::-1] + n = len(colours) + steps = int(self.steps.get() or 0) + return GradientScale(name, [(i / (n - 1), c) for i, c in enumerate(colours)], relative=True, + bins=steps or None, units=self.units, group=group) + thresholds = [] + for i, row in enumerate(self._band_rows[:-1]): + text = (row["upper"].get() or "").strip() + try: + thresholds.append(float(text)) + except ValueError: + raise ColourScaleError(f"Band {i + 1}: '{text}' is not a number.") from None + colours = [row["colour"].get() for row in self._band_rows] + labels = [(row["label"].get() or "").strip() or None for row in self._band_rows] + return ThresholdScale(name, thresholds, colours, labels=labels if any(labels) else None, + units=self.units, group=group) + + def _unused_name(self, base: str) -> str: + names = set(self.library.names()) + n = 1 + name = base + while name in names: + n += 1 + name = f"{base} {n}" + return name + + def _save(self) -> None: + """Validate, save to the library and close; errors are shown in a pop-up and stay in the window.""" + try: + scale = self.current_scale() + self.library.save(scale, overwrite=self.editing is not None and self.editing.name == scale.name) + except (ColourScaleError, OSError) as e: + message = str(e) + if "already exists" in message: + message += " Give this scale another name, or edit the saved one instead." + self.status.set(message) + box = WarningBox(title="Scale not saved", errors=[message], parent=self) + box.build() + box.run() + return + CONSOLE_LOGGER.info(f"Saved colour scale '{scale.name}' in set '{scale.group}'.") + self.saved_scale = scale + if self.on_save is not None: + self.on_save(scale) + self.destroy_root() + + # ── preview ──────────────────────────────────────────────────────────── + + def _schedule_preview(self) -> None: + if self._pending is not None: + try: + self.after_cancel(self._pending) + except tk.TclError: + pass + self._pending = self.after(250, self._update_preview) + + def _update_preview(self) -> None: + self._pending = None + try: + scale = self.current_scale() + except ColourScaleError as e: + self.status.set(str(e)) + return + self.status.set("") + if isinstance(scale, ThresholdScale): + for row, (lower, _upper) in zip(self._band_rows, scale.band_ranges()): + row["lower"].configure(text="below" if lower is None else f"{lower:.6g}") + self.preview.embed_figure(preview_figure(scale, self.values, self.summary)) + + +def preview_figure(scale: ColourScale, values: np.ndarray, summary: Optional[DataSummary]) -> Figure: + """Histogram of the data over the scale's colours (or the colours alone without data).""" + fig = Figure(figsize=(5.6, 1.3), dpi=100) + ax = fig.add_axes([0.04, 0.2, 0.94, 0.76]) + sample = values if values.size else scale.preview_data() + mapping = scale.mapping(sample) + lo, hi = (summary.minimum, summary.maximum) if summary else (float(sample.min()), float(sample.max())) + if lo == hi: + lo, hi = lo - 0.5, hi + 0.5 + pad = (hi - lo) * 0.04 + xs = np.linspace(lo - pad, hi + pad, 400) + colours = mapping.cmap(mapping.norm(xs)) + ax.imshow(colours[np.newaxis, :, :], aspect="auto", extent=(xs[0], xs[-1], 0, 1), alpha=0.85, + origin="lower", zorder=0) + if summary is not None: + counts, edges = summary.histogram + heights = counts / max(counts.max(), 1) + ax.bar(edges[:-1], heights, width=np.diff(edges), align="edge", color="#202020", alpha=0.55, + edgecolor="#ffffff", linewidth=0.6, zorder=1) + if isinstance(scale, ThresholdScale): + for t in scale.thresholds: + if xs[0] <= t <= xs[-1]: + ax.axvline(t, color="#000000", linewidth=1.2, zorder=2) + ax.set_xlim(xs[0], xs[-1]) + ax.set_ylim(0, 1.05) + ax.set_yticks([]) + ax.tick_params(labelsize=7) + for side in ("top", "right", "left"): + ax.spines[side].set_visible(False) + return fig + + diff --git a/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py b/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py new file mode 100644 index 0000000..a034516 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py @@ -0,0 +1,571 @@ +"""Colour scales defined in data units, with user-defined sets saved for reuse. + +A matplotlib Colormap only knows positions from 0 to 1, so a saved "18 / 21 / 24 degC" band +set drifts whenever the data range changes. A ColourScale keeps its definition in data +units and produces the matching (Colormap, Normalize) pair for whatever data is passed in: + + scale = ThresholdScale("Comfort", thresholds=[18, 21, 24], + colours=["#3b4cc0", "#8db0fe", "#f4987a", "#b40426"], + labels=["Cold", "Cool", "Comfortable", "Hot"], units="degC") + mapping = scale.mapping(room_temperatures) # pass the data in + ax.scatter(x, y, c=room_temperatures, cmap=mapping.cmap, norm=mapping.norm) + +Scales can be suggested from the data (equal interval, quantile, or rounded "nice" breaks), +and are saved in a ColourScaleLibrary under named sets (e.g. "TM59", "Part L"). +""" + +from __future__ import annotations + +import json +import math +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, ClassVar, Dict, Iterable, List, Optional, Sequence, Tuple, Type + +import numpy as np +from matplotlib import colormaps +from matplotlib.colors import ( + BoundaryNorm, + Colormap, + LinearSegmentedColormap, + ListedColormap, + Normalize, + to_hex, + to_rgba, +) + +from python_toolkit.bhom.logging import CONSOLE_LOGGER + +LIBRARY_FILE = Path.home() / ".bhom" / "colour_scales.json" +LIBRARY_VERSION = 1 +DEFAULT_SET = "My scales" + +# Preset palettes for building scales (colour-blind safe sets first). +PALETTES: Dict[str, List[str]] = { + "Okabe-Ito (colour-blind safe)": ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"], + "Viridis (colour-blind safe)": ["#440154", "#3b528b", "#21918c", "#5ec962", "#fde725"], + "Cividis (colour-blind safe)": ["#00204c", "#31446b", "#666870", "#988e5d", "#ffe945"], + "Cool to warm": ["#3b4cc0", "#8db0fe", "#dddddd", "#f4987a", "#b40426"], + "Traffic light": ["#1a9850", "#fee08b", "#d73027"], + "LTC Heatmap": ["#000004", "#51127c", "#b63679", "#fb8861", "#fcffa4"], + "LTC Maya": ["#5c8286", "#a1b18c", "#e7cca6", "#d98f79", "#b35b5a"], + "LTC Paloma": ["#1a2a40", "#456a73", "#82a69d", "#d9ccb6", "#bf8865"], +} + +# Palettes registered as matplotlib colour maps (with _r), so every colour map list offers them. +# Viridis and Cividis are already matplotlib maps. +PALETTE_CMAPS: Dict[str, str] = { + "Okabe-Ito (colour-blind safe)": "okabe_ito", + "Cool to warm": "cool_to_warm", + "Traffic light": "traffic_light", + "LTC Heatmap": "ltc_heatmap", + "LTC Maya": "ltc_maya", + "LTC Paloma": "ltc_paloma", +} + +SUGGESTION_METHODS = ("equal", "quantile", "nice") + + +class ColourScaleError(ValueError): + """A colour scale definition, or the saved library, is invalid.""" + + +# --------------------------------------------------------------------------- +# Data passed in +# --------------------------------------------------------------------------- + +def clean_data(data: Optional[Iterable]) -> np.ndarray: + """Finite float values from any iterable; None, NaN and non-numbers are dropped.""" + if data is None: + return np.empty(0) + numbers = np.array([_to_float(v) for v in np.ravel(np.asarray(list(data), dtype=object))], dtype=float) + return numbers[np.isfinite(numbers)] + + +@dataclass(frozen=True) +class DataSummary: + """What a scale needs to know about the data: range, spread and distribution.""" + + count: int + minimum: float + maximum: float + mean: float + quartiles: Tuple[float, float, float] + histogram: Tuple[np.ndarray, np.ndarray] # (counts, bin edges) + + @classmethod + def from_data(cls, data: Iterable, bins: int = 30) -> "DataSummary": + values = clean_data(data) + if values.size == 0: + raise ColourScaleError("No numeric values in the data passed in.") + lo, hi = float(values.min()), float(values.max()) + if lo == hi: + lo, hi = lo - 0.5, hi + 0.5 + counts, edges = np.histogram(values, bins=bins, range=(lo, hi)) + q1, q2, q3 = (float(q) for q in np.quantile(values, [0.25, 0.5, 0.75])) + return cls(int(values.size), float(values.min()), float(values.max()), float(values.mean()), + (q1, q2, q3), (counts, edges)) + + +def suggest_thresholds(data: Iterable, bands: int, method: str = "nice") -> List[float]: + """Inner boundaries splitting the data into `bands` bands. + + equal: equal-width bands across the data range + quantile: roughly the same number of values in each band + nice: equal-width bands on rounded 1 / 2 / 5 x 10^n steps + """ + if bands < 2: + raise ColourScaleError("A threshold scale needs at least 2 bands.") + if method not in SUGGESTION_METHODS: + raise ColourScaleError(f"Method must be one of {SUGGESTION_METHODS}, not '{method}'.") + values = clean_data(data) + if values.size == 0: + raise ColourScaleError("No numeric values to suggest thresholds from.") + lo, hi = float(values.min()), float(values.max()) + if lo == hi: + return [lo + i for i in range(1, bands)] + + if method == "equal": + return [float(v) for v in np.linspace(lo, hi, bands + 1)[1:-1]] + if method == "quantile": + cuts = np.quantile(values, np.linspace(0, 1, bands + 1)[1:-1]) + unique = sorted(set(float(c) for c in cuts)) + if len(unique) < bands - 1: + raise ColourScaleError("Too many repeated values for that many quantile bands; use fewer bands.") + return unique + + return _nice_thresholds(lo, hi, bands - 1) + + +def _nice_thresholds(lo: float, hi: float, wanted: int) -> List[float]: + """Rounded breaks (multiples of 1, 2, 2.5 or 5 x 10^n) strictly inside the data range. + + Picks the step whose breaks come closest to `wanted`, then keeps the middle `wanted` + of them, so every band contains data rather than extending beyond it.""" + raw = (hi - lo) / (wanted + 1) + exponent = math.floor(math.log10(raw)) + best = None + for power in (exponent - 1, exponent, exponent + 1): + for factor in (1, 2, 2.5, 5): + step = factor * 10 ** power + first, last = math.floor(lo / step) + 1, math.ceil(hi / step) - 1 + inner = [round(k * step, 10) for k in range(first, last + 1)] + if not inner: + continue + score = abs(len(inner) - wanted) + (0.5 if factor == 2.5 else 0.0) # prefer rounder steps + if best is None or score < best[0]: + best = (score, inner) + if best is None: # range too narrow for rounded breaks + return [float(v) for v in np.linspace(lo, hi, wanted + 2)[1:-1]] + inner = best[1] + if len(inner) > wanted: + start = (len(inner) - wanted) // 2 + inner = inner[start:start + wanted] + return inner + + +def register_palette_cmaps() -> None: + """Add the preset palettes to matplotlib's colour maps (idempotent).""" + for palette, name in PALETTE_CMAPS.items(): + if name not in colormaps: + cmap = LinearSegmentedColormap.from_list(name, PALETTES[palette]) + colormaps.register(cmap, name=name) + colormaps.register(cmap.reversed(), name=f"{name}_r") + + +def palette_names() -> List[str]: + """Preset palettes first, then every matplotlib colour map (no reversed copies).""" + preset = set(PALETTE_CMAPS.values()) + return list(PALETTES) + sorted((n for n in colormaps if not n.endswith("_r") and n not in preset), key=str.lower) + + +def palette_colours(palette: str, count: int) -> List[str]: + """`count` colours sampled evenly from a preset palette or a matplotlib colour map.""" + if palette in PALETTES: + cmap = LinearSegmentedColormap.from_list(palette, PALETTES[palette]) + elif palette in colormaps: + cmap = colormaps[palette] + else: + raise ColourScaleError(f"Unknown palette or colour map '{palette}'.") + if count == 1: + return [to_hex(cmap(0.5))] + return [to_hex(cmap(i / (count - 1))) for i in range(count)] + + +# --------------------------------------------------------------------------- +# Scales +# --------------------------------------------------------------------------- + +@dataclass +class ScaleMapping: + """Everything a plot needs to colour values with a scale.""" + + cmap: Colormap + norm: Normalize + ticks: List[float] + tick_labels: List[str] + band_labels: Optional[List[str]] = None # threshold scales: one per band, for legends + + def colours_for(self, values: Iterable) -> np.ndarray: + """RGBA per value (NaN for values that cannot be read).""" + data = np.array([_to_float(v) for v in values], dtype=float) + out = np.full((data.size, 4), np.nan) + ok = np.isfinite(data) + if ok.any(): + out[ok] = self.cmap(self.norm(data[ok])) + return out + + +class ColourScale(ABC): + """Base class: a named colour scale defined in data units. + + Subclasses produce a ScaleMapping from the data passed in, and round-trip through + plain dicts so they can be saved. `group` is the user-defined set the scale belongs to. + """ + + kind: ClassVar[str] = "" + _registry: ClassVar[Dict[str, Type["ColourScale"]]] = {} + + def __init__(self, name: str, units: Optional[str] = None, description: str = "", + group: str = DEFAULT_SET): + self.name = validate_name(name) + self.units = units + self.description = description + self.group = group or DEFAULT_SET + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if cls.kind: + ColourScale._registry[cls.kind] = cls + + @abstractmethod + def mapping(self, data: Optional[Iterable] = None) -> ScaleMapping: + """Colormap and normaliser for this scale, fitted to `data` where the scale is relative.""" + + @abstractmethod + def _definition(self) -> Dict[str, Any]: + """Subclass-specific fields for to_dict.""" + + def to_dict(self) -> Dict[str, Any]: + return {"type": self.kind, "name": self.name, "units": self.units, + "description": self.description, "group": self.group, **self._definition()} + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ColourScale": + kind = data.get("type") + scale_cls = ColourScale._registry.get(kind) + if scale_cls is None: + raise ColourScaleError(f"Unknown colour scale type '{kind}'.") + return scale_cls._from_definition(data) + + @classmethod + @abstractmethod + def _from_definition(cls, data: Dict[str, Any]) -> "ColourScale": + """Build from a dict written by to_dict.""" + + def preview_data(self) -> np.ndarray: + """Representative values spanning the scale, for swatches without real data.""" + return np.linspace(0.0, 1.0, 11) + + def to_colormap(self, data: Optional[Iterable] = None, samples: int = 256) -> Colormap: + """A plain 0-1 colormap that looks like this scale across its range (band widths kept), + for widgets that only take a Colormap. Plots should use `mapping`, which keeps data units.""" + mapping = self.mapping(data if data is not None else self.preview_data()) + values = np.linspace(mapping.norm.vmin, mapping.norm.vmax, samples) + return ListedColormap(mapping.cmap(mapping.norm(values)), name=self.name) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.name!r}, set={self.group!r})" + + +class GradientScale(ColourScale): + """A continuous gradient through colour stops. + + Stop positions are either absolute data values, or relative (0 = data minimum, + 1 = data maximum) so the gradient stretches to whatever data is passed in. + """ + + kind = "gradient" + + def __init__(self, name: str, stops: Sequence[Tuple[float, str]], relative: bool = True, + bins: Optional[int] = None, **kwargs): + super().__init__(name, **kwargs) + if len(stops) < 2: + raise ColourScaleError("A gradient needs at least 2 colour stops.") + self.stops = sorted(((float(p), _hex(c)) for p, c in stops), key=lambda s: s[0]) + self.relative = bool(relative) + self.bins = bins # None for smooth, or a number of discrete steps + positions = [p for p, _ in self.stops] + if positions[0] == positions[-1]: + raise ColourScaleError("Gradient stops must span a range.") + if self.relative and (positions[0] < 0 or positions[-1] > 1): + raise ColourScaleError("Relative stop positions must lie between 0 and 1.") + + @classmethod + def from_palette(cls, name: str, palette: str, **kwargs) -> "GradientScale": + if palette not in PALETTES: + raise ColourScaleError(f"Unknown palette '{palette}'.") + colours = PALETTES[palette] + n = len(colours) + return cls(name, [(i / (n - 1), c) for i, c in enumerate(colours)], relative=True, **kwargs) + + def value_range(self, data: Optional[Iterable] = None) -> Tuple[float, float]: + if not self.relative: + return self.stops[0][0], self.stops[-1][0] + values = clean_data(data) + if values.size == 0: + return 0.0, 1.0 + lo, hi = float(values.min()), float(values.max()) + return (lo - 0.5, hi + 0.5) if lo == hi else (lo, hi) + + def mapping(self, data: Optional[Iterable] = None) -> ScaleMapping: + vmin, vmax = self.value_range(data) + first, last = self.stops[0][0], self.stops[-1][0] + nodes = [((p - first) / (last - first), c) for p, c in self.stops] + cmap = LinearSegmentedColormap.from_list(self.name, nodes) + if self.bins: + cmap = cmap.resampled(int(self.bins)) + edges = list(np.linspace(vmin, vmax, int(self.bins) + 1)) + norm = BoundaryNorm(edges, ncolors=int(self.bins), clip=True) + ticks = edges + else: + norm = Normalize(vmin=vmin, vmax=vmax, clip=True) + ticks = list(np.linspace(vmin, vmax, 6)) + return ScaleMapping(cmap, norm, ticks, [_fmt(t) for t in ticks]) + + def _definition(self) -> Dict[str, Any]: + return {"stops": [[p, c] for p, c in self.stops], "relative": self.relative, "bins": self.bins} + + @classmethod + def _from_definition(cls, data: Dict[str, Any]) -> "GradientScale": + return cls(data["name"], [tuple(s) for s in data["stops"]], relative=data.get("relative", True), + bins=data.get("bins"), units=data.get("units"), description=data.get("description", ""), + group=data.get("group") or DEFAULT_SET) + + +class ThresholdScale(ColourScale): + """Bands with fixed boundaries in data units: one colour (and optional label) per band. + + `thresholds` are the inner boundaries; values below the first threshold take the first + colour and values at or above the last take the last, so the bands hold for any data. + """ + + kind = "thresholds" + + def __init__(self, name: str, thresholds: Sequence[float], colours: Sequence[str], + labels: Optional[Sequence[Optional[str]]] = None, **kwargs): + super().__init__(name, **kwargs) + self.thresholds = [float(t) for t in thresholds] + self.colours = [_hex(c) for c in colours] + if not self.thresholds: + raise ColourScaleError("A threshold scale needs at least one threshold.") + if any(b <= a for a, b in zip(self.thresholds, self.thresholds[1:])): + raise ColourScaleError("Thresholds must be strictly increasing.") + if len(self.colours) != len(self.thresholds) + 1: + raise ColourScaleError( + f"{len(self.thresholds)} threshold(s) make {len(self.thresholds) + 1} bands, " + f"but {len(self.colours)} colour(s) were given.") + if labels is not None and len(labels) != len(self.colours): + raise ColourScaleError(f"Give one label per band ({len(self.colours)}), or none.") + self.labels = [None if not l else str(l) for l in labels] if labels is not None else None + + @classmethod + def from_data(cls, name: str, data: Iterable, bands: int = 5, method: str = "nice", + palette: str = "Viridis (colour-blind safe)", **kwargs) -> "ThresholdScale": + """Suggest thresholds from the data passed in, coloured from a preset palette.""" + thresholds = suggest_thresholds(data, bands, method) + return cls(name, thresholds, palette_colours(palette, len(thresholds) + 1), **kwargs) + + @property + def bands(self) -> int: + return len(self.colours) + + def band_ranges(self) -> List[Tuple[Optional[float], Optional[float]]]: + """(lower, upper) per band; None for the open ends.""" + edges: List[Optional[float]] = [None] + list(self.thresholds) + [None] + return list(zip(edges[:-1], edges[1:])) + + def band_labels(self) -> List[str]: + """Labels as given, else ranges such as '< 18', '18 - 21', '>= 24'.""" + auto = [] + for lower, upper in self.band_ranges(): + if lower is None: + auto.append(f"< {_fmt(upper)}") + elif upper is None: + auto.append(f">= {_fmt(lower)}") + else: + auto.append(f"{_fmt(lower)} - {_fmt(upper)}") + if self.labels is None: + return auto + return [given or default for given, default in zip(self.labels, auto)] + + def band_of(self, value: float) -> int: + """Index of the band a value falls in.""" + return int(np.searchsorted(self.thresholds, float(value), side="right")) + + def mapping(self, data: Optional[Iterable] = None) -> ScaleMapping: + values = clean_data(data) + span = self.thresholds[-1] - self.thresholds[0] or 1.0 + lo = min(self.thresholds[0] - span / max(self.bands - 2, 1), float(values.min()) if values.size else math.inf) + hi = max(self.thresholds[-1] + span / max(self.bands - 2, 1), float(values.max()) if values.size else -math.inf) + edges = [lo] + self.thresholds + [hi + 1e-9] + cmap = ListedColormap(self.colours, name=self.name) + norm = BoundaryNorm(edges, ncolors=self.bands, clip=True) + return ScaleMapping(cmap, norm, list(self.thresholds), [_fmt(t) for t in self.thresholds], + band_labels=self.band_labels()) + + def preview_data(self) -> np.ndarray: + span = self.thresholds[-1] - self.thresholds[0] or 1.0 + pad = span / max(self.bands - 2, 1) + return np.linspace(self.thresholds[0] - pad, self.thresholds[-1] + pad, 101) + + def _definition(self) -> Dict[str, Any]: + return {"thresholds": self.thresholds, "colours": self.colours, "labels": self.labels} + + @classmethod + def _from_definition(cls, data: Dict[str, Any]) -> "ThresholdScale": + return cls(data["name"], data["thresholds"], data["colours"], labels=data.get("labels"), + units=data.get("units"), description=data.get("description", ""), + group=data.get("group") or DEFAULT_SET) + + +# --------------------------------------------------------------------------- +# Library: saved scales grouped in user-defined sets +# --------------------------------------------------------------------------- + +class ColourScaleLibrary: + """Saved colour scales, grouped into named sets, stored as JSON. + + Entries that cannot be read are kept on disk, reported in `problems` and logged, + never silently dropped. + """ + + def __init__(self, path: Optional[Path] = None): + self.path = Path(path) if path is not None else LIBRARY_FILE + self.problems: List[str] = [] + self._scales: Dict[str, ColourScale] = {} + self._unreadable: List[Dict[str, Any]] = [] + self.reload() + + def reload(self) -> None: + self._scales.clear() + self._unreadable.clear() + self.problems = [] + if not self.path.exists(): + return + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + raise ColourScaleError(f"Cannot read the colour scale library {self.path}: {e}") from e + + entries = raw.get("scales", []) if isinstance(raw, dict) else raw + if not isinstance(entries, list): + raise ColourScaleError(f"{self.path} is not a colour scale library.") + for entry in entries: + try: + scale = ColourScale.from_dict(entry) + except (KeyError, TypeError, ValueError) as e: + name = entry.get("name", "?") if isinstance(entry, dict) else "?" + self.problems.append(f"Could not read saved scale '{name}': {e}") + self._unreadable.append(entry) + continue + self._scales[scale.name] = scale + for problem in self.problems: + CONSOLE_LOGGER.warning(problem) + + # -- queries ------------------------------------------------------------ + + def names(self, group: Optional[str] = None) -> List[str]: + return [s.name for s in self._scales.values() if group is None or s.group == group] + + def groups(self) -> List[str]: + """The user-defined sets, in first-seen order.""" + return list(dict.fromkeys(s.group for s in self._scales.values())) + + def scales(self, group: Optional[str] = None) -> List[ColourScale]: + return [s for s in self._scales.values() if group is None or s.group == group] + + def get(self, name: str) -> ColourScale: + if name not in self._scales: + raise KeyError(f"No saved colour scale named '{name}'.") + return self._scales[name] + + def __contains__(self, name: str) -> bool: + return name in self._scales + + def __len__(self) -> int: + return len(self._scales) + + # -- changes ------------------------------------------------------------ + + def save(self, scale: ColourScale, overwrite: bool = False) -> None: + if scale.name in self._scales and not overwrite: + raise ColourScaleError(f"A scale named '{scale.name}' already exists.") + self._scales[scale.name] = scale + self._write() + + def delete(self, name: str) -> None: + if name not in self._scales: + raise KeyError(f"No saved colour scale named '{name}'.") + del self._scales[name] + self._write() + + def rename_group(self, old: str, new: str) -> None: + for scale in self.scales(old): + scale.group = new + self._write() + + def delete_group(self, group: str) -> None: + for name in self.names(group): + del self._scales[name] + self._write() + + def clear(self) -> None: + self._scales.clear() + self._write() + + def _write(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + payload = {"version": LIBRARY_VERSION, + "scales": [s.to_dict() for s in self._scales.values()] + self._unreadable} + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8") + tmp.replace(self.path) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_INVALID_NAME_CHARS = set(r'\/:*?"<>|') + + +def validate_name(name: str) -> str: + name = (name or "").strip() + if not name: + raise ColourScaleError("A colour scale needs a name.") + if any(c in _INVALID_NAME_CHARS for c in name): + raise ColourScaleError("Names cannot contain \\ / : * ? \" < > |") + return name + + +def _hex(colour: str) -> str: + try: + return to_hex(to_rgba(colour)) + except ValueError as e: + raise ColourScaleError(f"'{colour}' is not a colour.") from e + + +def _to_float(value) -> float: + try: + return float(value) + except (TypeError, ValueError): + return math.nan + + +def _fmt(value: float) -> str: + return f"{value:.4g}" + + +register_palette_cmaps() diff --git a/Python_Engine/Python/tests/test_colour_scale_builder.py b/Python_Engine/Python/tests/test_colour_scale_builder.py new file mode 100644 index 0000000..5a80508 --- /dev/null +++ b/Python_Engine/Python/tests/test_colour_scale_builder.py @@ -0,0 +1,136 @@ +import numpy as np +import pytest + +from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow +from python_toolkit.bhom_tkinter.widgets.cmap_selector import CmapSelector +from python_toolkit.bhom_tkinter.windows import ColourScaleBuilder +from python_toolkit.bhom_tkinter.windows.colour_scale_builder import GRADIENT, preview_figure +from python_toolkit.plot.colour_scales import ColourScaleLibrary, DataSummary, GradientScale, ThresholdScale + + +@pytest.fixture(scope="module") +def host(): + """One Tk root for the module: creating a Tk root per test intermittently fails to start Tcl on Windows.""" + root = BHoMBaseWindow(title="Builder host", show_submit=False, show_close=False) + root.withdraw() + yield root + root.destroy_root() + + +@pytest.fixture(autouse=True) +def warnings_shown(monkeypatch): + """Record warning pop-ups instead of blocking on them.""" + from python_toolkit.bhom_tkinter.windows import colour_scale_builder + + shown = [] + + class _Box: + def __init__(self, title, errors, parent=None): + shown.append(errors) + + def build(self): + pass + + def run(self): + pass + + monkeypatch.setattr(colour_scale_builder, "WarningBox", _Box) + return shown + + +@pytest.fixture +def library(tmp_path): + return ColourScaleLibrary(tmp_path / "scales.json") + + +def test_builder_suggests_bands_from_data(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), units="degC", library=library) + scale = builder.current_scale() + assert isinstance(scale, ThresholdScale) + assert scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) + assert scale.units == "degC" + assert "200 values from 16 to 30 degC" in builder.data_text.get() + + +def test_builder_saves_labels_into_a_set(host, library): + saved = [] + builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library=library, on_save=saved.append) + builder.name.set("Comfort") + builder.group.value_var.set("Overheating") # typed into the editable set box + builder._band_rows[0]["label"].set("Cold") + builder._save() + reopened = ColourScaleLibrary(library.path) + scale = reopened.get("Comfort") + assert saved and saved[0].name == "Comfort" + assert scale.group == "Overheating" and scale.band_labels()[0] == "Cold" + + +def test_builder_reports_bad_thresholds_without_saving(host, library, warnings_shown): + builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library=library) + builder._band_rows[0]["upper"].set("warm") + builder._save() + assert builder.saved_scale is None and "not a number" in builder.status.get() + assert warnings_shown and "not a number" in warnings_shown[0][0] # shown in a pop-up, not only in the window + assert len(library) == 0 + + +def test_builder_name_clash_is_explained_and_default_names_are_free(host, library, warnings_shown): + library.save(ThresholdScale("New scale", [1, 2], ["#000000", "#808080", "#ffffff"])) + builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library=library) + assert builder.name.get() == "New scale 2" + builder.name.set("New scale") + builder._save() + assert builder.saved_scale is None and "another name" in warnings_shown[-1][0] + builder.name.set("Renamed") + builder._update_preview() # what the name change schedules + assert builder.status.get() == "" # the old error does not linger + + +def test_builder_gradient_and_edit(host, library): + builder = ColourScaleBuilder(host, data=[0, 1, 2], library=library) + builder.kind.set(GRADIENT) + builder._on_kind_changed() + assert isinstance(builder.current_scale(), GradientScale) + existing = ThresholdScale("Existing", [1, 2], ["#000000", "#808080", "#ffffff"], labels=["a", "b", "c"]) + editor = ColourScaleBuilder(host, scale=existing, library=library) + assert editor.current_scale().band_labels() == ["a", "b", "c"] + + +def test_preview_with_and_without_data(): + scale = ThresholdScale("T", [20, 25], ["#0000ff", "#00ff00", "#ff0000"]) + values = np.linspace(16, 30, 50) + assert preview_figure(scale, values, DataSummary.from_data(values)).axes + assert preview_figure(scale, np.array([]), None).axes + + +def test_builder_starts_from_a_colour_map_and_recolours(host, library): + from python_toolkit.plot.colour_scales import palette_colours + + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 50), library=library, palette="magma") + assert builder.palette.get() == "magma" + assert [r["colour"].get() for r in builder._band_rows] == palette_colours("magma", 5) + builder.palette.set("Traffic light") + builder._recolour() + scale = builder.current_scale() + assert scale.colours == palette_colours("Traffic light", 5) and scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) + + +def test_cmap_selector_lists_saved_scales_and_adds_new_ones(host, tmp_path, monkeypatch): + from python_toolkit.plot import colour_scales + + monkeypatch.setattr(colour_scales, "LIBRARY_FILE", tmp_path / "scales.json") + ColourScaleLibrary().save(ThresholdScale("Comfort", [18, 24], ["#0000ff", "#00ff00", "#ff0000"])) + selector = CmapSelector(host.content_frame, colormaps=["viridis"], allow_custom=True) + assert list(selector.cmap_combobox["values"]) == ["Comfort", "viridis"] + assert selector.get_cmap().name == "Comfort" + + changes = [] + selector.on_change = changes.append + selector._on_scale_saved(GradientScale("Warm", [(0, "#ffffff"), (1, "#ff0000")])) + assert list(selector.cmap_combobox["values"]) == ["Warm", "Comfort", "viridis"] + assert selector.get_cmap().name == "Warm" and len(changes) == 1 + + +def test_cmap_selector_without_custom_has_no_button(host): + selector = CmapSelector(host.content_frame, colormaps=["viridis"]) + assert list(selector.cmap_combobox["values"]) == ["viridis"] and not hasattr(selector, "new_scale_button") diff --git a/Python_Engine/Python/tests/test_colour_scales.py b/Python_Engine/Python/tests/test_colour_scales.py new file mode 100644 index 0000000..843ec51 --- /dev/null +++ b/Python_Engine/Python/tests/test_colour_scales.py @@ -0,0 +1,184 @@ +import json + +import numpy as np +import pytest +from matplotlib.colors import to_hex + +from python_toolkit.plot.colour_scales import ( + ColourScale, + ColourScaleError, + ColourScaleLibrary, + DataSummary, + GradientScale, + PALETTES, + ThresholdScale, + palette_colours, + suggest_thresholds, +) + +COMFORT = dict(thresholds=[18, 21, 24], colours=["#0000ff", "#00ffff", "#00ff00", "#ff0000"], + labels=["Cold", "Cool", "Comfortable", "Hot"], units="degC", group="TM59") + + +def colour_of(mapping, value): + return to_hex(mapping.cmap(mapping.norm(value))) + + +# -- thresholds hold in data units, whatever data is passed in ---------------- + +@pytest.mark.parametrize("data", [[19, 20, 22], np.linspace(-50, 200, 30), [23.9], None]) +def test_bands_fixed_for_any_data(data): + mapping = ThresholdScale("Comfort", **COMFORT).mapping(data) + assert colour_of(mapping, 17.99) == "#0000ff" + assert colour_of(mapping, 18.0) == "#00ffff" # boundary belongs to the band above + assert colour_of(mapping, 22.0) == "#00ff00" + assert colour_of(mapping, 24.0) == "#ff0000" + assert colour_of(mapping, -1000) == "#0000ff" and colour_of(mapping, 1000) == "#ff0000" + assert mapping.band_labels == ["Cold", "Cool", "Comfortable", "Hot"] + + +def test_auto_band_labels_and_band_of(): + scale = ThresholdScale("Plain", [10, 20], ["red", "green", "blue"]) + assert scale.band_labels() == ["< 10", "10 - 20", ">= 20"] + assert [scale.band_of(v) for v in (5, 10, 19.9, 20, 99)] == [0, 1, 1, 2, 2] + partly = ThresholdScale("Partly", [10, 20], ["red", "green", "blue"], labels=["Low", None, ""]) + assert partly.band_labels() == ["Low", "10 - 20", ">= 20"] + + +@pytest.mark.parametrize("kwargs,message", [ + (dict(thresholds=[], colours=["red"]), "at least one"), + (dict(thresholds=[2, 1], colours=["r", "g", "b"]), "increasing"), + (dict(thresholds=[1, 2], colours=["red", "green"]), "3 bands"), + (dict(thresholds=[1], colours=["red", "green"], labels=["a"]), "one label per band"), + (dict(thresholds=[1], colours=["red", "notacolour"]), "not a colour"), +]) +def test_threshold_validation(kwargs, message): + with pytest.raises(ColourScaleError, match=message): + ThresholdScale("Bad", **kwargs) + + +def test_gradient_relative_stretches_to_data_absolute_does_not(): + relative = GradientScale.from_palette("Rel", "Traffic light") + assert relative.mapping([10, 30]).norm.vmin == 10 and relative.mapping([10, 30]).norm.vmax == 30 + absolute = GradientScale("Abs", [(0, "blue"), (100, "red")], relative=False) + assert (absolute.mapping([10, 30]).norm.vmin, absolute.mapping([10, 30]).norm.vmax) == (0, 100) + stepped = GradientScale("Steps", [(0, "blue"), (1, "red")], bins=4).mapping([0, 8]) + assert len({colour_of(stepped, v) for v in np.linspace(0, 8, 50)}) == 4 + with pytest.raises(ColourScaleError): + GradientScale("Bad", [(0, "red"), (2, "blue")], relative=True) + + +def test_mapping_colours_for_handles_missing_values(): + rgba = ThresholdScale("C", **COMFORT).mapping().colours_for([17, None, "x", 30]) + assert np.isnan(rgba[1]).all() and np.isnan(rgba[2]).all() + assert to_hex(rgba[3]) == "#ff0000" + + +# -- data passed in ---------------------------------------------------------- + +def test_data_summary_ignores_unreadable_values(): + summary = DataSummary.from_data([1, 2, None, "n/a", float("nan"), 3, 10]) + assert summary.count == 4 and summary.minimum == 1 and summary.maximum == 10 + assert summary.histogram[0].sum() == 4 + with pytest.raises(ColourScaleError): + DataSummary.from_data([None, "x"]) + + +def test_suggest_thresholds_methods(): + data = np.concatenate([np.full(90, 20.0), np.linspace(20, 30, 10)]) + assert suggest_thresholds([0, 100], 4, "equal") == [25, 50, 75] + assert suggest_thresholds([0.3, 9.7], 5, "nice") == [2, 4, 6, 8] + with pytest.raises(ColourScaleError, match="repeated values"): + suggest_thresholds(data, 4, "quantile") + q = suggest_thresholds(np.arange(100), 4, "quantile") + assert len(q) == 3 and q[0] < q[1] < q[2] + + +@pytest.mark.parametrize("data,bands,expected", [ + ([16, 30], 5, [17.5, 20, 22.5, 25]), + ([8, 24], 4, [10, 15, 20]), + ([0, 95], 4, [25, 50, 75]), + ([1000, 250000], 5, [50000, 100000, 150000, 200000]), +]) +def test_nice_thresholds_fall_inside_the_data(data, bands, expected): + thresholds = suggest_thresholds(data, bands, "nice") + assert thresholds == pytest.approx(expected) + assert all(min(data) < t < max(data) for t in thresholds) + + +def test_threshold_scale_from_data(): + scale = ThresholdScale.from_data("Loads", np.linspace(0, 95, 50), bands=4, method="nice", + palette="Okabe-Ito (colour-blind safe)", group="Loads") + assert scale.thresholds == [25, 50, 75] and scale.bands == 4 and scale.group == "Loads" + assert len(palette_colours("Traffic light", 7)) == 7 + + +# -- persistence ------------------------------------------------------------- + +def test_round_trip_through_dict(): + for scale in (ThresholdScale("Comfort", **COMFORT), GradientScale.from_palette("G", "LTC Maya", bins=6)): + rebuilt = ColourScale.from_dict(json.loads(json.dumps(scale.to_dict()))) + assert type(rebuilt) is type(scale) and rebuilt.to_dict() == scale.to_dict() + + +def test_library_sets(tmp_path): + library = ColourScaleLibrary(tmp_path / "scales.json") + library.save(ThresholdScale("Comfort", **COMFORT)) + library.save(GradientScale.from_palette("Heat", "LTC Heatmap", group="Loads")) + library.save(ThresholdScale("Overheating", [26, 28], ["green", "orange", "red"], group="TM59")) + reopened = ColourScaleLibrary(tmp_path / "scales.json") + assert reopened.groups() == ["TM59", "Loads"] + assert reopened.names("TM59") == ["Comfort", "Overheating"] + assert reopened.get("Comfort").labels[-1] == "Hot" + with pytest.raises(ColourScaleError, match="already exists"): + reopened.save(ThresholdScale("Comfort", **COMFORT)) + reopened.rename_group("TM59", "Overheating checks") + reopened.delete_group("Loads") + final = ColourScaleLibrary(tmp_path / "scales.json") + assert final.groups() == ["Overheating checks"] and len(final) == 2 + + +def test_unreadable_entries_reported_and_kept(tmp_path): + path = tmp_path / "mixed.json" + path.write_text(json.dumps({"version": 2, "scales": [ + ThresholdScale("Good", [1], ["red", "blue"]).to_dict(), + {"type": "thresholds", "name": "Broken", "thresholds": [2, 1], "colours": ["a"]}, + ]}), encoding="utf-8") + library = ColourScaleLibrary(path) + assert library.names() == ["Good"] and "Broken" in library.problems[0] + library.save(ThresholdScale("New", [5], ["red", "blue"])) + names_on_disk = [e["name"] for e in json.loads(path.read_text(encoding="utf-8"))["scales"]] + assert "Broken" in names_on_disk # never silently deleted + + +def test_corrupt_library_raises(tmp_path): + path = tmp_path / "bad.json" + path.write_text("{not json", encoding="utf-8") + with pytest.raises(ColourScaleError, match="Cannot read"): + ColourScaleLibrary(path) + + +def test_palettes_are_valid(): + for name, colours in PALETTES.items(): + assert len(colours) >= 3, name + GradientScale.from_palette(name, name) + + +def test_palettes_registered_as_colour_maps(): + from matplotlib import colormaps + from python_toolkit.plot.colour_scales import PALETTE_CMAPS, palette_names + + for name in PALETTE_CMAPS.values(): + assert name in colormaps and f"{name}_r" in colormaps + names = palette_names() + assert names[:len(PALETTES)] == list(PALETTES) and "magma" in names and "okabe_ito" not in names + assert palette_colours("magma", 3)[0] == "#000004" + with pytest.raises(ColourScaleError, match="Unknown palette"): + palette_colours("no such map", 3) + + +def test_to_colormap_keeps_band_widths(): + scale = ThresholdScale("T", [10, 30], ["#0000ff", "#00ff00", "#ff0000"]) + cmap = scale.to_colormap(np.array([0, 40])) + colours = [to_hex(cmap(x)) for x in (0.1, 0.5, 0.9)] + assert colours == ["#0000ff", "#00ff00", "#ff0000"] and cmap.name == "T" From 2f1610401cc411c0be1698b78d2759b252d588b8 Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 28 Oct 2026 16:39:27 +0000 Subject: [PATCH 2/3] refactored to better combine cmaps and pallettes --- .../bhom_tkinter/widgets/cmap_selector.py | 122 +--- .../windows/colour_scale_builder.py | 413 +++++++----- .../src/python_toolkit/plot/colour_scales.py | 612 +++++------------- .../src/python_toolkit/plot/palettes.py | 66 ++ .../Python/tests/test_colour_scale_builder.py | 227 +++++-- .../Python/tests/test_colour_scales.py | 238 +++---- 6 files changed, 773 insertions(+), 905 deletions(-) create mode 100644 Python_Engine/Python/src/python_toolkit/plot/palettes.py diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py index 9165a0f..446ac2d 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/widgets/cmap_selector.py @@ -9,8 +9,7 @@ from python_toolkit.bhom_tkinter.widgets.figure_container import FigureContainer from python_toolkit.bhom_tkinter.widgets._widgets_base import BHoMBaseWidget from python_toolkit.bhom_tkinter.widgets._packing_options import PackingOptions -from python_toolkit.bhom_tkinter.widgets.button import Button -from python_toolkit.plot.colour_scales import ColourScale, ColourScaleLibrary +from python_toolkit.plot import palettes from python_toolkit.bhom.analytics import CONSOLE_LOGGER from python_toolkit.bhom_tkinter.theming.scaling import scale_pad @@ -29,48 +28,6 @@ class CmapSelector(BHoMBaseWidget): * `List[str]`: a list of colour strings (hex codes or named colours) that is auto-converted to a `ListedColormap`. """ - CATEGORICAL_CMAPS = [ - "Accent", - "Dark2", - "Paired", - "Pastel1", - "Pastel2", - "Set1", - "Set2", - "Set3", - "tab10", - "tab20", - "tab20b", - "tab20c", - ] - - CONTINUOUS_CMAPS = [ - "viridis", - "plasma", - "inferno", - "magma", - "cividis", - "turbo", - "Blues", - "Greens", - "Greys", - "Oranges", - "Purples", - "Reds", - "YlGn", - "YlGnBu", - "YlOrBr", - "YlOrRd", - "coolwarm", - "seismic", - "Spectral", - "RdYlBu", - "RdYlGn", - "twilight", - "twilight_shifted", - "hsv", - ] - def __init__( self, parent: ttk.Frame, @@ -80,7 +37,6 @@ def __init__( default_cmap: Optional[CmapInput] = None, plot_size: tuple[int, int] = (400, 50), dropdown_position: Literal["n", "e", "s", "w"] = "n", - allow_custom: bool = False, **kwargs ) -> None: """ @@ -93,7 +49,8 @@ def __init__( `List[str]` of colour strings. When provided the preset `cmap_set` selection is ignored. cmap_set: Preset colormap set used when `colormaps` is `None`. - Allowed values: "all", "continuous", "categorical". + Allowed values: "all", "continuous", "categorical", "colourblind" + (see `python_toolkit.plot.palettes`). cmap_bins: Number of discrete gradient bands in the preview swatch. default_cmap: Optional default colormap to pre-select. Accepts the same input types as items in `colormaps`. @@ -101,9 +58,6 @@ def __init__( dropdown_position: Position of the dropdown relative to the preview swatch. "n" = above, "s" = below, "w" = left, "e" = right. - allow_custom: When True, colour scales saved in the - `ColourScaleLibrary` are listed first, and a button opens the - `ColourScaleBuilder` to make a new one. **kwargs: Additional Frame options. """ super().__init__(parent, **kwargs) @@ -119,12 +73,7 @@ def __init__( self.cmap_frame.pack(side="top", fill="both", expand=True, anchor=self._pack_anchor) self.colormap_var = tk.StringVar(value="viridis") - self._all_colormaps = self._get_all_colormaps() - self._preset_map: Dict[str, List[str]] = { - "all": self._all_colormaps, - "continuous": self._filter_available(self.CONTINUOUS_CMAPS), - "categorical": self._filter_available(self.CATEGORICAL_CMAPS), - } + self._all_colormaps = palettes.names() self._uses_explicit_colormaps = colormaps is not None self.cmap_frame.columnconfigure(0, weight=1) @@ -172,73 +121,12 @@ def __init__( else: current_colormaps = self._preset_colormaps(self.cmap_set_var.get()) - if allow_custom: - current_colormaps = self._saved_scale_labels(current_colormaps) + current_colormaps - self._populate_cmap_list(current_colormaps) # Resolve default_cmap: may itself be a Colormap object or colour list self._default_label = self._resolve_default_label(default_cmap, current_colormaps) self._select_default_cmap(current_colormaps) - if allow_custom: - self.new_scale_button = Button(header, text="New colour scale...", command=self._open_scale_builder) - self.new_scale_button.pack(side=tk.TOP, anchor=self._pack_anchor, fill="x", pady=(4, 0)) - - def _saved_scale_labels(self, existing: List[str]) -> List[str]: - """Register every saved colour scale as a custom entry and return their labels.""" - labels: List[str] = [] - for scale in ColourScaleLibrary().scales(): - label = self._unique_label(scale.name, existing + labels) - self._custom_cmaps[label] = scale.to_colormap() - labels.append(label) - return labels - - def _open_scale_builder(self) -> None: - """Open the colour scale builder; a saved scale is added to the list and selected.""" - # imported here: the builder window is built from these widgets - from python_toolkit.bhom_tkinter.windows.colour_scale_builder import ColourScaleBuilder - - ColourScaleBuilder(self.winfo_toplevel(), on_save=self._on_scale_saved).run() - - def _on_scale_saved(self, scale: ColourScale) -> None: - existing = list(self.cmap_combobox["values"]) - label = self._unique_label(scale.name, existing) - self._custom_cmaps[label] = scale.to_colormap() - self._populate_cmap_list([label] + existing) - self.colormap_var.set(label) - self._update_cmap_sample() - self._fire_on_change(self.get()) - - def _get_all_colormaps(self) -> List[str]: - """Return all registered colormap names, including reversed variants. - - Returns: - List[str]: Sorted list of available colormap names. - """ - # Base names available in this matplotlib build - base_names = set(mpl.colormaps()) - - # Include reversed variants (name_r) next to each base map - all_names = set(base_names) - for name in list(base_names): - if not name.endswith("_r"): - all_names.add(f"{name}_r") - - return sorted(all_names) - - def _filter_available(self, names: List[str]) -> List[str]: - """Filter a candidate list to names available in the current matplotlib build. - - Args: - names: Candidate colormap names. - - Returns: - List[str]: Candidate names available in the current environment. - """ - available = set(self._all_colormaps) - return [name for name in names if name in available] - def _with_reversed(self, names: List[str]) -> List[str]: """Return colormap names with reversed variants next to base maps. @@ -269,7 +157,7 @@ def _preset_colormaps(self, cmap_set: str) -> List[str]: List[str]: Colormap names for the requested preset. """ key = (cmap_set or "all").lower() - return self._with_reversed(self._preset_map.get(key, self._preset_map["all"])) + return self._with_reversed(palettes.names(key if key in palettes.KINDS else None)) def _resolve_colormaps(self, colormaps: List[CmapInput]) -> tuple[List[str], Dict[str, Colormap]]: """Normalise a mixed list of colormap inputs into display labels and a custom-cmap registry. diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py index bf2e531..86dc896 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py @@ -1,8 +1,8 @@ """Window for building colour scales from the data they will colour. Pass the data in and the window shows its distribution, suggests band thresholds, and -lets the user colour, label and save the scale into a named set of the colour scale -library (``python_toolkit.plot.colour_scales``):: +lets the user colour, label and save the scale to the saved scales file +(``python_toolkit.plot.colour_scales``):: builder = ColourScaleBuilder(parent, data=room_temperatures, units="degC") builder.run() @@ -11,8 +11,10 @@ from __future__ import annotations +import math import tkinter as tk from tkinter import ttk +from pathlib import Path from typing import Callable, Iterable, List, Optional import numpy as np @@ -22,39 +24,48 @@ from python_toolkit.bhom_tkinter.bhom_base_child_window import BHoMBaseChildWindow from python_toolkit.bhom_tkinter.windows.warning_box import WarningBox from python_toolkit.bhom_tkinter.widgets._grid_options import GridOptions -from python_toolkit.bhom_tkinter.widgets.button import Button -from python_toolkit.bhom_tkinter.widgets.check_box_selection import CheckboxSelection from python_toolkit.bhom_tkinter.widgets.colour_picker import ColourPicker from python_toolkit.bhom_tkinter.widgets.drop_down_selection import DropDownSelection from python_toolkit.bhom_tkinter.widgets.figure_container import FigureContainer from python_toolkit.bhom_tkinter.widgets.label import Label -from python_toolkit.bhom_tkinter.widgets.radio_selection import RadioSelection from python_toolkit.bhom_tkinter.widgets.spinbox import Spinbox from python_toolkit.bhom_tkinter.widgets.validated_entry_box import ValidatedEntryBox +from python_toolkit.bhom_tkinter.theming.scaling import scale as scale_px +from python_toolkit.plot import palettes from python_toolkit.plot.colour_scales import ( - DEFAULT_SET, - PALETTES, SUGGESTION_METHODS, ColourScale, ColourScaleError, - ColourScaleLibrary, - DataSummary, - GradientScale, - ThresholdScale, clean_data, - palette_colours, - palette_names, + load_scales, + save_scale, suggest_thresholds, ) -THRESHOLDS = "Threshold bands" -GRADIENT = "Gradient" MAX_BANDS = 12 +TYPING_DELAY = 700 # ms to wait after the last keystroke, so "22" is not read as "2" first +PLOT_WIDTH = 560 # the plot, the controls and the band table all share this width ERROR_COLOUR = "#c0392b" +def _spin_step(span: float) -> float: + """A round step (1, 2 or 5 x 10^n) of about a hundredth of the span, for spinning a threshold.""" + raw = span / 100 if span > 0 else 0.01 + power = 10 ** math.floor(math.log10(raw)) + return next(factor * power for factor in (5, 2, 1) if factor * power <= raw) + + +def _plain(box: ValidatedEntryBox) -> ValidatedEntryBox: + """Drop the tick and the message line an entry box reserves, so its edges line up with its neighbours. + + Problems are reported in the window's status line instead.""" + box.success_label.pack_forget() + box.error_label.pack_forget() + return box + + class ColourScaleBuilder(BHoMBaseChildWindow): - """Build, preview and save a ThresholdScale or GradientScale against real data.""" + """Build, preview and save a ColourScale against real data.""" def __init__( self, @@ -62,7 +73,7 @@ def __init__( data: Optional[Iterable] = None, units: Optional[str] = None, scale: Optional[ColourScale] = None, - library: Optional[ColourScaleLibrary] = None, + library_path: Optional[Path] = None, on_save: Optional[Callable[[ColourScale], None]] = None, palette: Optional[str] = None, **kwargs, @@ -73,19 +84,20 @@ def __init__( data: Values the scale will colour; drives the histogram and suggestions. units: Units of the data, stored with the scale. scale: An existing scale to edit (saving replaces it). - library: Library to save into (defaults to the user's library). + library_path: File to save into (defaults to the user's saved scales). on_save: Called with the saved scale. palette: Palette or matplotlib colour map to start from (e.g. the plot's current one). """ self.values = clean_data(data) - self.summary = DataSummary.from_data(self.values) if self.values.size else None self.units = units if units is not None else (scale.units if scale else None) - self.library = library if library is not None else ColourScaleLibrary() + self.library_path = library_path self.editing = scale self.on_save = on_save self.saved_scale: Optional[ColourScale] = None self._band_rows: List[dict] = [] self._pending = None + self._syncing = False + self._suggest_job = None self._start_palette = palette super().__init__( parent, @@ -97,108 +109,100 @@ def __init__( modal=parent is not None, defer_show=True, resizable=True, - min_width=kwargs.pop("min_width", 620), - min_height=kwargs.pop("min_height", 800), + min_width=kwargs.pop("min_width", 400), + min_height=kwargs.pop("min_height", 400), **kwargs, ) self._build_contents() self.build() self._load(scale) + self.update_idletasks() + self._apply_sizing() # the band rows were added after the window first sized itself # ── layout ───────────────────────────────────────────────────────────── def _build_contents(self) -> None: frame = self.content_frame - sets = self.library.groups() or [DEFAULT_SET] - default_set = self.editing.group if self.editing else sets[0] - - self.name = ValidatedEntryBox(frame, id="name", helper_text="Name", required=False, width=30, - on_change=lambda _v: self._schedule_preview(), # re-checks, clearing old errors - build_options=GridOptions(row=0, column=0, sticky="w", padx=6, pady=3)) - self.group = DropDownSelection(frame, id="group", helper_text="Set (type a new name to start a set)", - options=sets, default=default_set, state="normal", width=28, - build_options=GridOptions(row=0, column=1, sticky="w", padx=6, pady=3)) - self.kind = RadioSelection(frame, id="kind", fields=[THRESHOLDS, GRADIENT], default=THRESHOLDS, - orient="horizontal", command=lambda _v: self._on_kind_changed(), - build_options=GridOptions(row=1, column=0, columnspan=2, sticky="w", padx=6, pady=3)) - self.data_text = Label(frame, text=self._summary_text(), - build_options=GridOptions(row=2, column=0, columnspan=2, sticky="w", padx=6)) - self.preview = FigureContainer(frame, id="preview", rigid_width=560, rigid_height=130, - build_options=GridOptions(row=3, column=0, columnspan=2, sticky="w", padx=6, pady=4)) - - suggest = ttk.Frame(frame) - suggest.grid(row=5, column=0, columnspan=2, sticky="w", padx=6, pady=3) - self.band_count = Spinbox(suggest, id="bands", helper_text="Bands", values=list(range(2, MAX_BANDS + 1)), - default=5, width=5, build_options=GridOptions(row=0, column=0, sticky="w", padx=(0, 8))) - self.method = DropDownSelection(suggest, id="method", helper_text="Breaks", options=list(SUGGESTION_METHODS), - default="nice", state="readonly", width=10, + frame.columnconfigure(0, weight=1) + left = dict(column=0, sticky="ew", padx=0) + + self.name = _plain(ValidatedEntryBox( + frame, id="name", helper_text="Name", required=False, width=30, + on_change=lambda _v: self._schedule_preview(TYPING_DELAY), # re-checks, clearing old errors + build_options=GridOptions(row=0, pady=3, **left))) + self.data_text = Label(frame, text=self._summary_text(), build_options=GridOptions(row=2, **left)) + self.preview = FigureContainer(frame, id="preview", rigid_width=PLOT_WIDTH, rigid_height=130, + build_options=GridOptions(row=3, pady=4, **left)) + self.status = Label(frame, text="", foreground=ERROR_COLOUR, wraplength=scale_px(PLOT_WIDTH), justify="left", + build_options=GridOptions(row=4, **left)) + + controls = ttk.Frame(frame) + controls.grid(row=5, pady=3, **left) + controls.columnconfigure(2, weight=1) # the palette takes the spare width + self.band_count = Spinbox(controls, id="bands", helper_text="Bands", values=list(range(2, MAX_BANDS + 1)), + default=5, width=4, command=lambda _v: self._auto_suggest(TYPING_DELAY), + build_options=GridOptions(row=0, column=0, sticky="w", padx=(0, 8))) + self.method = DropDownSelection(controls, id="method", helper_text="Breaks", options=list(SUGGESTION_METHODS), + default="nice", state="readonly", width=7, + command=lambda _v: self._auto_suggest(0), build_options=GridOptions(row=0, column=1, sticky="w", padx=(0, 8))) - palettes = palette_names() - self.palette = DropDownSelection(suggest, id="palette", helper_text="Palette / colour map", options=palettes, - default=self._start_palette if self._start_palette in palettes else None, - state="readonly", width=28, command=lambda _v: self._recolour(), - build_options=GridOptions(row=0, column=2, sticky="w", padx=(0, 8))) - self.suggest_button = Button(suggest, text="Suggest from data", command=self._suggest, width=18, - build_options=GridOptions(row=0, column=3, sticky="sw")) - for widget in (self.band_count, self.method, self.palette, self.suggest_button): + options = palettes.names() + self.palette = DropDownSelection(controls, id="palette", helper_text="Palette / colour map", options=options, + default=self._start_palette if self._start_palette in options else "viridis", + state="readonly", width=6, command=lambda _v: self._recolour(), + build_options=GridOptions(row=0, column=2, sticky="ew")) + for widget in (self.band_count, self.method, self.palette): widget.build() - if self.summary is None: - self.suggest_button.button.configure(state="disabled") + if not self.values.size: # breaks are suggested from data; without it the thresholds are typed in + self.band_count.spinbox.configure(state="disabled") + self.method.combobox.configure(state="disabled") self.bands_frame = ttk.Frame(frame) - self.bands_frame.grid(row=6, column=0, columnspan=2, sticky="w", padx=6, pady=3) - self.gradient_frame = ttk.Frame(frame) - self.gradient_frame.grid(row=6, column=0, columnspan=2, sticky="w", padx=6, pady=3) - self.reverse = CheckboxSelection(self.gradient_frame, id="reverse", fields=["Reverse"], - command=lambda _v: self._schedule_preview(), - build_options=GridOptions(row=0, column=0, sticky="w", padx=(0, 12))) - self.steps = Spinbox(self.gradient_frame, id="steps", helper_text="Steps (0 = smooth)", - values=[0] + list(range(2, 21)), default=0, width=5, - command=lambda _v: self._schedule_preview(), - build_options=GridOptions(row=0, column=1, sticky="w")) - self.reverse.build() - self.steps.build() - - self.status = Label(frame, text="", foreground=ERROR_COLOUR, wraplength=560, justify="left", - build_options=GridOptions(row=4, column=0, columnspan=2, sticky="w", padx=6)) - self.widgets = [self.name, self.group, self.kind, self.data_text, self.preview, self.status] + self.bands_frame.grid(row=6, pady=3, **left) + self.bands_frame.columnconfigure(1, weight=1) # the label box takes the spare width + self.widgets = [self.name, self.data_text, self.preview, self.status] def _summary_text(self) -> str: - if self.summary is None: + if not self.values.size: return "No data passed in: set the thresholds by hand." - s = self.summary + q1, q2, q3 = np.quantile(self.values, [0.25, 0.5, 0.75]) units = f" {self.units}" if self.units else "" - return (f"{s.count} values from {s.minimum:.4g} to {s.maximum:.4g}{units}; " - f"median {s.quartiles[1]:.4g}, middle half {s.quartiles[0]:.4g} to {s.quartiles[2]:.4g}") + return (f"{self.values.size} values from {self.values.min():.4g} to {self.values.max():.4g}{units}; " + f"median {q2:.4g}, middle half {q1:.4g} to {q3:.4g}") # ── state ────────────────────────────────────────────────────────────── def _load(self, scale: Optional[ColourScale]) -> None: - if isinstance(scale, GradientScale): + if scale is not None: self.name.set(scale.name) - self.kind.set(GRADIENT) - self.steps.set(scale.bins or 0) - elif isinstance(scale, ThresholdScale): - self.name.set(scale.name) - self._set_bands(scale.thresholds, scale.colours, scale.labels) - elif self.summary is not None: + if isinstance(scale.cmap, str): + self.palette.set(scale.cmap) + self._set_bands(scale.thresholds, scale.colours(), scale.labels) + elif self.values.size: self.name.set(self._unused_name("New scale")) self._suggest() else: self.name.set(self._unused_name("New scale")) - self._set_bands([1.0, 2.0], palette_colours(self.palette.get(), 3), None) - self._on_kind_changed() - - def _on_kind_changed(self) -> None: - threshold = self.kind.get() == THRESHOLDS - if threshold: - self.gradient_frame.grid_remove() - self.bands_frame.grid() - else: - self.bands_frame.grid_remove() - self.gradient_frame.grid() + self._set_bands([1.0, 2.0], palettes.sample(self.palette.get(), 3), None) self._schedule_preview() + def _auto_suggest(self, delay: int) -> None: + """Re-suggest the bands once the band count or break method has settled.""" + if self._syncing or not self.values.size: + return + if self._suggest_job is not None: + self.after_cancel(self._suggest_job) + self._suggest_job = self.after(delay, self._run_auto_suggest) + + def _run_auto_suggest(self) -> None: + self._suggest_job = None + try: + bands = int(self.band_count.get()) + except ValueError: # half-typed count + return + if 2 <= bands <= MAX_BANDS: + self._suggest() + def _suggest(self) -> None: try: bands = int(self.band_count.get()) @@ -206,13 +210,75 @@ def _suggest(self) -> None: except (ColourScaleError, ValueError) as e: self.status.set(str(e)) return - self._set_bands(thresholds, palette_colours(self.palette.get(), len(thresholds) + 1), None) + n = len(thresholds) + 1 + palette = self.palette.get() + old_colours = [row["colour"].get() for row in self._band_rows] + old_labels = [(row["label"].get() or "").strip() or None for row in self._band_rows] + fresh = palettes.sample(palette, n) + # keep hand-picked colours and any labels on the bands that remain; new breaks only replace the thresholds + customised = bool(old_colours) and old_colours != palettes.sample(palette, len(old_colours)) + colours = [old_colours[i] if customised and i < len(old_colours) else fresh[i] for i in range(n)] + labels = (old_labels + [None] * n)[:n] + self._set_bands(thresholds, colours, labels if any(labels) else None) + + def _split_band(self, index: int) -> None: + """Add a band by splitting this one in two at the middle of its range; the new band starts empty.""" + try: + thresholds, colours, labels = self._read_bands() + except ColourScaleError as e: + self.status.set(str(e)) + return + n = len(colours) + if n >= MAX_BANDS: + return + typical = float(np.mean(np.diff(thresholds))) if len(thresholds) > 1 else 0.0 + if not typical and self.values.size: + typical = float(self.values.max() - self.values.min()) / n + typical = typical or 1.0 + lower = thresholds[index - 1] if index > 0 else None + upper = thresholds[index] if index < n - 1 else None + data_lo = float(self.values.min()) if self.values.size else None + data_hi = float(self.values.max()) if self.values.size else None + # an open end is split against the data's extreme, or one typical band width beyond the last threshold + lo = lower if lower is not None else (data_lo if data_lo is not None and data_lo < upper else upper - typical) + hi = upper if upper is not None else (data_hi if data_hi is not None and data_hi > lower else lower + typical) + new = float(f"{(lo + hi) / 2:.6g}") + if not lo < new < hi: + self.status.set("There is no room to split this band; move its thresholds apart first.") + return + was_customised = colours != palettes.sample(self.palette.get(), n) + thresholds.insert(index, new) + colours.insert(index + 1, colours[index]) + labels.insert(index + 1, None) + self._rebuild(thresholds, colours, labels, was_customised) + + def _remove_band(self, index: int) -> None: + """Remove a band; its range joins the band after it (before it, for the last band).""" + try: + thresholds, colours, labels = self._read_bands() + except ColourScaleError as e: + self.status.set(str(e)) + return + n = len(colours) + if n <= 2: + return + was_customised = colours != palettes.sample(self.palette.get(), n) + thresholds.pop(index if index < n - 1 else n - 2) + colours.pop(index) + labels.pop(index) + self._rebuild(thresholds, colours, labels, was_customised) + + def _rebuild(self, thresholds, colours, labels, was_customised: bool) -> None: + """Show an edited table: hand-picked colours stay, otherwise the palette is spread over the new bands.""" + if not was_customised: + colours = palettes.sample(self.palette.get(), len(colours)) + self._set_bands(thresholds, colours, labels if any(labels) else None) def _recolour(self) -> None: """A new palette recolours the bands, keeping their thresholds and labels.""" if not self._band_rows: return - for row, colour in zip(self._band_rows, palette_colours(self.palette.get(), len(self._band_rows))): + for row, colour in zip(self._band_rows, palettes.sample(self.palette.get(), len(self._band_rows))): row["colour"].set(colour) self._schedule_preview() @@ -221,58 +287,77 @@ def _set_bands(self, thresholds, colours, labels) -> None: child.destroy() self._band_rows = [] n = len(colours) - headers = ("Colour", "Label (legend)", "From", "Up to") + # thresholds are spin boxes: step about 1% of the data range, anywhere from one range below to one above + lo = min([*thresholds, *([self.values.min()] if self.values.size else [])]) + hi = max([*thresholds, *([self.values.max()] if self.values.size else [])]) + span = (hi - lo) or 1.0 + step = _spin_step(span) + headers = ("Colour", "Label (legend)", "From", "Up to", "") for col, text in enumerate(headers): - ttk.Label(self.bands_frame, text=text, style="Caption.TLabel").grid(row=0, column=col, sticky="w", padx=4) + ttk.Label(self.bands_frame, text=text, style="Caption.TLabel").grid(row=0, column=col, sticky="w", padx=(0, 8)) for i in range(n): picker = ColourPicker(self.bands_frame, default_colour=colours[i], swatch_width=36, swatch_height=20, command=lambda _c: self._schedule_preview()) - picker.grid(row=i + 1, column=0, sticky="nw", padx=4, pady=(6, 0)) - label = ValidatedEntryBox(self.bands_frame, required=False, width=22, - on_change=lambda _v: self._schedule_preview()) - label.grid(row=i + 1, column=1, sticky="w", padx=4) + picker.grid(row=i + 1, column=0, sticky="w", padx=(0, 8)) + label = _plain(ValidatedEntryBox(self.bands_frame, required=False, width=10, + on_change=lambda _v: self._schedule_preview(TYPING_DELAY))) + label.grid(row=i + 1, column=1, sticky="ew", padx=(0, 8)) label.set((labels[i] or "") if labels else "") lower = ttk.Label(self.bands_frame, text="") - lower.grid(row=i + 1, column=2, sticky="nw", padx=4, pady=(6, 0)) + lower.grid(row=i + 1, column=2, sticky="w", padx=(0, 8)) upper = None if i < n - 1: - upper = ValidatedEntryBox(self.bands_frame, required=False, width=10, - on_change=lambda _v: self._schedule_preview()) - upper.grid(row=i + 1, column=3, sticky="w", padx=4) + upper = Spinbox(self.bands_frame, from_=lo - span, to=hi + span, increment=step, width=10, + command=lambda _v: self._schedule_preview(TYPING_DELAY)) + upper.spinbox.configure(format="%.6g") # no float noise while spinning + upper.grid(row=i + 1, column=3, sticky="w") upper.set(f"{thresholds[i]:.6g}") else: - ttk.Label(self.bands_frame, text="and above").grid(row=i + 1, column=3, sticky="nw", padx=4, pady=(6, 0)) - self._band_rows.append({"colour": picker, "label": label, "lower": lower, "upper": upper}) + ttk.Label(self.bands_frame, text="and above").grid(row=i + 1, column=3, sticky="w") + actions = ttk.Frame(self.bands_frame) + actions.grid(row=i + 1, column=4, sticky="e", padx=(8, 0)) + split = ttk.Button(actions, text="Split", width=6, command=lambda i=i: self._split_band(i)) + remove = ttk.Button(actions, text="Remove", width=7, command=lambda i=i: self._remove_band(i)) + split.pack(side="left", padx=(0, 4)) + remove.pack(side="left") + if n >= MAX_BANDS: + split.state(["disabled"]) + if n <= 2: + remove.state(["disabled"]) + self._band_rows.append({"colour": picker, "label": label, "lower": lower, "upper": upper, + "split": split, "remove": remove}) + self._syncing = True # showing the count must not re-suggest the bands self.band_count.set(n) + self._syncing = False self._schedule_preview() + if self._has_been_shown: # the window follows the number of band rows + self.update_idletasks() + self._apply_sizing() - def current_scale(self) -> ColourScale: - """The scale described by the window right now (raises ColourScaleError if invalid).""" - name = (self.name.get() or "").strip() - group = (self.group.get() or "").strip() or DEFAULT_SET - if self.kind.get() == GRADIENT: - name = self.palette.get() - colours = PALETTES.get(name) or palette_colours(name, 9) - if self.reverse.get(): - colours = colours[::-1] - n = len(colours) - steps = int(self.steps.get() or 0) - return GradientScale(name, [(i / (n - 1), c) for i, c in enumerate(colours)], relative=True, - bins=steps or None, units=self.units, group=group) + def _read_bands(self): + """The table as (thresholds, colours, labels); raises ColourScaleError for a threshold that is not a number.""" thresholds = [] for i, row in enumerate(self._band_rows[:-1]): - text = (row["upper"].get() or "").strip() + text = str(row["upper"].get()).strip() try: thresholds.append(float(text)) except ValueError: raise ColourScaleError(f"Band {i + 1}: '{text}' is not a number.") from None colours = [row["colour"].get() for row in self._band_rows] labels = [(row["label"].get() or "").strip() or None for row in self._band_rows] - return ThresholdScale(name, thresholds, colours, labels=labels if any(labels) else None, - units=self.units, group=group) + return thresholds, colours, labels + + def current_scale(self) -> ColourScale: + """The scale described by the window right now (raises ColourScaleError if invalid).""" + name = (self.name.get() or "").strip() + palette = self.palette.get() + thresholds, colours, labels = self._read_bands() + # keep the palette as the scale's colours unless a band colour was changed by hand + cmap = palette if colours == palettes.sample(palette, len(colours)) else colours + return ColourScale(name, thresholds, cmap, labels if any(labels) else None, self.units) def _unused_name(self, base: str) -> str: - names = set(self.library.names()) + names = {s.name for s in load_scales(self.library_path)} n = 1 name = base while name in names: @@ -284,7 +369,7 @@ def _save(self) -> None: """Validate, save to the library and close; errors are shown in a pop-up and stay in the window.""" try: scale = self.current_scale() - self.library.save(scale, overwrite=self.editing is not None and self.editing.name == scale.name) + save_scale(scale, self.library_path, overwrite=self.editing is not None and self.editing.name == scale.name) except (ColourScaleError, OSError) as e: message = str(e) if "already exists" in message: @@ -294,7 +379,7 @@ def _save(self) -> None: box.build() box.run() return - CONSOLE_LOGGER.info(f"Saved colour scale '{scale.name}' in set '{scale.group}'.") + CONSOLE_LOGGER.info(f"Saved colour scale '{scale.name}'.") self.saved_scale = scale if self.on_save is not None: self.on_save(scale) @@ -302,13 +387,13 @@ def _save(self) -> None: # ── preview ──────────────────────────────────────────────────────────── - def _schedule_preview(self) -> None: + def _schedule_preview(self, delay: int = 250) -> None: if self._pending is not None: try: self.after_cancel(self._pending) except tk.TclError: pass - self._pending = self.after(250, self._update_preview) + self._pending = self.after(delay, self._update_preview) def _update_preview(self) -> None: self._pending = None @@ -318,41 +403,55 @@ def _update_preview(self) -> None: self.status.set(str(e)) return self.status.set("") - if isinstance(scale, ThresholdScale): - for row, (lower, _upper) in zip(self._band_rows, scale.band_ranges()): - row["lower"].configure(text="below" if lower is None else f"{lower:.6g}") - self.preview.embed_figure(preview_figure(scale, self.values, self.summary)) - - -def preview_figure(scale: ColourScale, values: np.ndarray, summary: Optional[DataSummary]) -> Figure: - """Histogram of the data over the scale's colours (or the colours alone without data).""" - fig = Figure(figsize=(5.6, 1.3), dpi=100) - ax = fig.add_axes([0.04, 0.2, 0.94, 0.76]) - sample = values if values.size else scale.preview_data() - mapping = scale.mapping(sample) - lo, hi = (summary.minimum, summary.maximum) if summary else (float(sample.min()), float(sample.max())) - if lo == hi: - lo, hi = lo - 0.5, hi + 0.5 - pad = (hi - lo) * 0.04 - xs = np.linspace(lo - pad, hi + pad, 400) - colours = mapping.cmap(mapping.norm(xs)) - ax.imshow(colours[np.newaxis, :, :], aspect="auto", extent=(xs[0], xs[-1], 0, 1), alpha=0.85, + for row, lower in zip(self._band_rows, [None] + scale.thresholds): + row["lower"].configure(text="below" if lower is None else f"{lower:.6g}") + style = ttk.Style() + self.preview.embed_figure(preview_figure(scale, self.values, + background=style.lookup("TFrame", "background") or "#ffffff", + foreground=style.lookup("TLabel", "foreground") or "#000000")) + + +def preview_figure(scale: ColourScale, values: np.ndarray, background: str = "#ffffff", + foreground: str = "#000000") -> Figure: + """Histogram of the data over the scale's colours (or the colours alone without data). + + `background` and `foreground` are the window's colours, so the plot sits in light or dark mode. + """ + fig = Figure(figsize=(5.6, 1.3), dpi=100, facecolor=background) + ax = fig.add_axes([0.0, 0.2, 1.0, 0.76], facecolor=background) + cmap, norm = scale.mapping() + lo, hi = scale.value_range(values) + xs = np.linspace(lo, hi, 400) + ax.imshow(cmap(norm(xs))[np.newaxis, :, :], aspect="auto", extent=(xs[0], xs[-1], 0, 1), alpha=0.85, origin="lower", zorder=0) - if summary is not None: - counts, edges = summary.histogram - heights = counts / max(counts.max(), 1) - ax.bar(edges[:-1], heights, width=np.diff(edges), align="edge", color="#202020", alpha=0.55, - edgecolor="#ffffff", linewidth=0.6, zorder=1) - if isinstance(scale, ThresholdScale): - for t in scale.thresholds: - if xs[0] <= t <= xs[-1]: - ax.axvline(t, color="#000000", linewidth=1.2, zorder=2) + if values.size: + counts, edges = np.histogram(values, bins=30, range=(lo, hi)) + ax.bar(edges[:-1], counts / max(counts.max(), 1), width=np.diff(edges), align="edge", color=foreground, + alpha=0.45, edgecolor=background, linewidth=0.6, zorder=1) + for t in scale.thresholds: + if xs[0] <= t <= xs[-1]: + ax.axvline(t, color=foreground, linewidth=1.2, zorder=2) ax.set_xlim(xs[0], xs[-1]) ax.set_ylim(0, 1.05) ax.set_yticks([]) - ax.tick_params(labelsize=7) + ax.tick_params(labelsize=7, colors=foreground) for side in ("top", "right", "left"): ax.spines[side].set_visible(False) + ax.spines["bottom"].set_color(foreground) return fig +if __name__ == "__main__": + import tempfile + + # Build a colour scale from sample data; saved to a temp file so your own saved scales are untouched. + temperatures = np.random.default_rng(1).normal(22, 3, 300) + builder = ColourScaleBuilder( + data=temperatures, + units="degC", + palette="okabe_ito", + library_path=Path(tempfile.gettempdir()) / "colour_scale_builder_demo.json", + on_save=lambda scale: print(f"Saved: {scale!r}"), + ) + builder.run() + print(builder.saved_scale) diff --git a/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py b/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py index a034516..7d5d035 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py +++ b/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py @@ -1,112 +1,124 @@ -"""Colour scales defined in data units, with user-defined sets saved for reuse. +"""Colour scales: data broken into bands at thresholds in data units, coloured by a palette. A matplotlib Colormap only knows positions from 0 to 1, so a saved "18 / 21 / 24 degC" band -set drifts whenever the data range changes. A ColourScale keeps its definition in data -units and produces the matching (Colormap, Normalize) pair for whatever data is passed in: +set drifts whenever the data range changes. A ColourScale keeps its thresholds in data units +and produces the matching (Colormap, Normalize) pair, whatever data it is used on: - scale = ThresholdScale("Comfort", thresholds=[18, 21, 24], - colours=["#3b4cc0", "#8db0fe", "#f4987a", "#b40426"], - labels=["Cold", "Cool", "Comfortable", "Hot"], units="degC") - mapping = scale.mapping(room_temperatures) # pass the data in - ax.scatter(x, y, c=room_temperatures, cmap=mapping.cmap, norm=mapping.norm) + scale = ColourScale("Comfort", thresholds=[18, 21, 24], cmap="coolwarm", + labels=["Cold", "Cool", "Comfortable", "Hot"], units="degC") + cmap, norm = scale.mapping() + ax.scatter(x, y, c=room_temperatures, cmap=cmap, norm=norm) + scale.cmap = "okabe_ito" # the colours can change; the thresholds stay -Scales can be suggested from the data (equal interval, quantile, or rounded "nice" breaks), -and are saved in a ColourScaleLibrary under named sets (e.g. "TM59", "Part L"). +`cmap` is a palette name (see `python_toolkit.plot.palettes`) or an explicit list of colours, +one per band. """ from __future__ import annotations import json import math -from abc import ABC, abstractmethod -from dataclasses import dataclass, field from pathlib import Path -from typing import Any, ClassVar, Dict, Iterable, List, Optional, Sequence, Tuple, Type +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union import numpy as np from matplotlib import colormaps -from matplotlib.colors import ( - BoundaryNorm, - Colormap, - LinearSegmentedColormap, - ListedColormap, - Normalize, - to_hex, - to_rgba, -) +from matplotlib.colors import BoundaryNorm, Colormap, ListedColormap, to_hex, to_rgba from python_toolkit.bhom.logging import CONSOLE_LOGGER +from python_toolkit.plot import palettes LIBRARY_FILE = Path.home() / ".bhom" / "colour_scales.json" -LIBRARY_VERSION = 1 -DEFAULT_SET = "My scales" - -# Preset palettes for building scales (colour-blind safe sets first). -PALETTES: Dict[str, List[str]] = { - "Okabe-Ito (colour-blind safe)": ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"], - "Viridis (colour-blind safe)": ["#440154", "#3b528b", "#21918c", "#5ec962", "#fde725"], - "Cividis (colour-blind safe)": ["#00204c", "#31446b", "#666870", "#988e5d", "#ffe945"], - "Cool to warm": ["#3b4cc0", "#8db0fe", "#dddddd", "#f4987a", "#b40426"], - "Traffic light": ["#1a9850", "#fee08b", "#d73027"], - "LTC Heatmap": ["#000004", "#51127c", "#b63679", "#fb8861", "#fcffa4"], - "LTC Maya": ["#5c8286", "#a1b18c", "#e7cca6", "#d98f79", "#b35b5a"], - "LTC Paloma": ["#1a2a40", "#456a73", "#82a69d", "#d9ccb6", "#bf8865"], -} - -# Palettes registered as matplotlib colour maps (with _r), so every colour map list offers them. -# Viridis and Cividis are already matplotlib maps. -PALETTE_CMAPS: Dict[str, str] = { - "Okabe-Ito (colour-blind safe)": "okabe_ito", - "Cool to warm": "cool_to_warm", - "Traffic light": "traffic_light", - "LTC Heatmap": "ltc_heatmap", - "LTC Maya": "ltc_maya", - "LTC Paloma": "ltc_paloma", -} - -SUGGESTION_METHODS = ("equal", "quantile", "nice") +SUGGESTION_METHODS = ("equal", "quantile", "nice", "log") class ColourScaleError(ValueError): """A colour scale definition, or the saved library, is invalid.""" -# --------------------------------------------------------------------------- -# Data passed in -# --------------------------------------------------------------------------- +class ColourScale: + """A named set of bands split at `thresholds` (data units), coloured by `cmap`.""" -def clean_data(data: Optional[Iterable]) -> np.ndarray: - """Finite float values from any iterable; None, NaN and non-numbers are dropped.""" - if data is None: - return np.empty(0) - numbers = np.array([_to_float(v) for v in np.ravel(np.asarray(list(data), dtype=object))], dtype=float) - return numbers[np.isfinite(numbers)] + def __init__(self, name: str, thresholds: Sequence[float], cmap: Union[str, Sequence[str]] = "viridis", + labels: Optional[Sequence[Optional[str]]] = None, units: Optional[str] = None): + self.name = (name or "").strip() + if not self.name: + raise ColourScaleError("A colour scale needs a name.") + self.thresholds = [float(t) for t in thresholds] + if not self.thresholds: + raise ColourScaleError("A colour scale needs at least one threshold.") + if any(b <= a for a, b in zip(self.thresholds, self.thresholds[1:])): + raise ColourScaleError("Thresholds must be strictly increasing.") + if labels is not None and len(labels) != self.bands: + raise ColourScaleError(f"Give one label per band ({self.bands}), or none.") + self.labels = [str(l) if l else None for l in labels] if labels is not None else None + self.units = units + self.cmap = cmap + @property + def bands(self) -> int: + return len(self.thresholds) + 1 -@dataclass(frozen=True) -class DataSummary: - """What a scale needs to know about the data: range, spread and distribution.""" + @property + def cmap(self) -> Union[str, List[str]]: + return self._cmap + + @cmap.setter + def cmap(self, value: Union[str, Sequence[str]]) -> None: + if isinstance(value, str): + if value not in colormaps: + raise ColourScaleError(f"Unknown colour map '{value}'.") + self._cmap = value + return + colours = [_hex(c) for c in value] + if len(colours) != self.bands: + raise ColourScaleError(f"{len(self.thresholds)} threshold(s) make {self.bands} bands, " + f"but {len(colours)} colour(s) were given.") + self._cmap = colours - count: int - minimum: float - maximum: float - mean: float - quartiles: Tuple[float, float, float] - histogram: Tuple[np.ndarray, np.ndarray] # (counts, bin edges) + def colours(self) -> List[str]: + """One hex colour per band.""" + return list(self._cmap) if not isinstance(self._cmap, str) else palettes.sample(self._cmap, self.bands) - @classmethod - def from_data(cls, data: Iterable, bins: int = 30) -> "DataSummary": + def value_range(self, data: Optional[Iterable] = None) -> Tuple[float, float]: + """The data range, or a span around the thresholds without data.""" values = clean_data(data) - if values.size == 0: - raise ColourScaleError("No numeric values in the data passed in.") - lo, hi = float(values.min()), float(values.max()) - if lo == hi: - lo, hi = lo - 0.5, hi + 0.5 - counts, edges = np.histogram(values, bins=bins, range=(lo, hi)) - q1, q2, q3 = (float(q) for q in np.quantile(values, [0.25, 0.5, 0.75])) - return cls(int(values.size), float(values.min()), float(values.max()), float(values.mean()), - (q1, q2, q3), (counts, edges)) + if values.size: + lo, hi = float(values.min()), float(values.max()) + else: + pad = (self.thresholds[-1] - self.thresholds[0]) / max(self.bands - 2, 1) or 1.0 + lo, hi = self.thresholds[0] - pad, self.thresholds[-1] + pad + return (lo - 0.5, hi + 0.5) if lo == hi else (lo, hi) + + def mapping(self) -> Tuple[Colormap, BoundaryNorm]: + """The colormap and normaliser to colour any data with; values beyond the ends take the end colours.""" + edges = [self.thresholds[0] - 1] + self.thresholds + [self.thresholds[-1] + 1] + return ListedColormap(self.colours(), name=self.name), BoundaryNorm(edges, self.bands, clip=True) + + def band_labels(self) -> List[str]: + """Labels as given, else ranges such as '< 18', '18 - 21', '>= 24'.""" + t = self.thresholds + auto = [f"< {_fmt(t[0])}"] + [f"{_fmt(a)} - {_fmt(b)}" for a, b in zip(t, t[1:])] + [f">= {_fmt(t[-1])}"] + given = self.labels or [None] * len(auto) + return [g or a for g, a in zip(given, auto)] + + def to_dict(self) -> Dict[str, Any]: + return {"name": self.name, "thresholds": self.thresholds, "cmap": self._cmap, + "labels": self.labels, "units": self.units} + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ColourScale": + # "colours" is the older name for the colour list + return cls(data["name"], data["thresholds"], data.get("cmap") or data["colours"], + data.get("labels"), data.get("units")) + + def __repr__(self) -> str: + return f"ColourScale({self.name!r}, thresholds={self.thresholds}, cmap={self._cmap!r})" + + +# --------------------------------------------------------------------------- +# Suggesting thresholds from data +# --------------------------------------------------------------------------- def suggest_thresholds(data: Iterable, bands: int, method: str = "nice") -> List[float]: """Inner boundaries splitting the data into `bands` bands. @@ -114,6 +126,8 @@ def suggest_thresholds(data: Iterable, bands: int, method: str = "nice") -> List equal: equal-width bands across the data range quantile: roughly the same number of values in each band nice: equal-width bands on rounded 1 / 2 / 5 x 10^n steps + log: bands of equal ratio (each break a fixed multiple of the last), rounded to 3 significant + figures; needs every value above zero """ if bands < 2: raise ColourScaleError("A threshold scale needs at least 2 bands.") @@ -134,24 +148,28 @@ def suggest_thresholds(data: Iterable, bands: int, method: str = "nice") -> List if len(unique) < bands - 1: raise ColourScaleError("Too many repeated values for that many quantile bands; use fewer bands.") return unique - + if method == "log": + if lo <= 0: + raise ColourScaleError("Logarithmic breaks need every value above zero; use another break method.") + cuts = [float(f"{v:.3g}") for v in np.geomspace(lo, hi, bands + 1)[1:-1]] + if any(b <= a for a, b in zip(cuts, cuts[1:])) or cuts[0] <= lo or cuts[-1] >= hi: + raise ColourScaleError("The data range is too narrow for that many logarithmic bands; use fewer bands.") + return cuts return _nice_thresholds(lo, hi, bands - 1) def _nice_thresholds(lo: float, hi: float, wanted: int) -> List[float]: """Rounded breaks (multiples of 1, 2, 2.5 or 5 x 10^n) strictly inside the data range. - Picks the step whose breaks come closest to `wanted`, then keeps the middle `wanted` + Picks the step with the fewest breaks that is still at least `wanted`, then keeps the middle `wanted` of them, so every band contains data rather than extending beyond it.""" - raw = (hi - lo) / (wanted + 1) - exponent = math.floor(math.log10(raw)) + exponent = math.floor(math.log10((hi - lo) / (wanted + 1))) best = None for power in (exponent - 1, exponent, exponent + 1): for factor in (1, 2, 2.5, 5): step = factor * 10 ** power - first, last = math.floor(lo / step) + 1, math.ceil(hi / step) - 1 - inner = [round(k * step, 10) for k in range(first, last + 1)] - if not inner: + inner = [round(k * step, 10) for k in range(math.floor(lo / step) + 1, math.ceil(hi / step))] + if len(inner) < wanted: # always return the number of breaks asked for continue score = abs(len(inner) - wanted) + (0.5 if factor == 2.5 else 0.0) # prefer rounder steps if best is None or score < best[0]: @@ -159,395 +177,62 @@ def _nice_thresholds(lo: float, hi: float, wanted: int) -> List[float]: if best is None: # range too narrow for rounded breaks return [float(v) for v in np.linspace(lo, hi, wanted + 2)[1:-1]] inner = best[1] - if len(inner) > wanted: - start = (len(inner) - wanted) // 2 - inner = inner[start:start + wanted] - return inner - - -def register_palette_cmaps() -> None: - """Add the preset palettes to matplotlib's colour maps (idempotent).""" - for palette, name in PALETTE_CMAPS.items(): - if name not in colormaps: - cmap = LinearSegmentedColormap.from_list(name, PALETTES[palette]) - colormaps.register(cmap, name=name) - colormaps.register(cmap.reversed(), name=f"{name}_r") - - -def palette_names() -> List[str]: - """Preset palettes first, then every matplotlib colour map (no reversed copies).""" - preset = set(PALETTE_CMAPS.values()) - return list(PALETTES) + sorted((n for n in colormaps if not n.endswith("_r") and n not in preset), key=str.lower) - - -def palette_colours(palette: str, count: int) -> List[str]: - """`count` colours sampled evenly from a preset palette or a matplotlib colour map.""" - if palette in PALETTES: - cmap = LinearSegmentedColormap.from_list(palette, PALETTES[palette]) - elif palette in colormaps: - cmap = colormaps[palette] - else: - raise ColourScaleError(f"Unknown palette or colour map '{palette}'.") - if count == 1: - return [to_hex(cmap(0.5))] - return [to_hex(cmap(i / (count - 1))) for i in range(count)] - - -# --------------------------------------------------------------------------- -# Scales -# --------------------------------------------------------------------------- - -@dataclass -class ScaleMapping: - """Everything a plot needs to colour values with a scale.""" - - cmap: Colormap - norm: Normalize - ticks: List[float] - tick_labels: List[str] - band_labels: Optional[List[str]] = None # threshold scales: one per band, for legends - - def colours_for(self, values: Iterable) -> np.ndarray: - """RGBA per value (NaN for values that cannot be read).""" - data = np.array([_to_float(v) for v in values], dtype=float) - out = np.full((data.size, 4), np.nan) - ok = np.isfinite(data) - if ok.any(): - out[ok] = self.cmap(self.norm(data[ok])) - return out - - -class ColourScale(ABC): - """Base class: a named colour scale defined in data units. - - Subclasses produce a ScaleMapping from the data passed in, and round-trip through - plain dicts so they can be saved. `group` is the user-defined set the scale belongs to. - """ - - kind: ClassVar[str] = "" - _registry: ClassVar[Dict[str, Type["ColourScale"]]] = {} - - def __init__(self, name: str, units: Optional[str] = None, description: str = "", - group: str = DEFAULT_SET): - self.name = validate_name(name) - self.units = units - self.description = description - self.group = group or DEFAULT_SET - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - if cls.kind: - ColourScale._registry[cls.kind] = cls - - @abstractmethod - def mapping(self, data: Optional[Iterable] = None) -> ScaleMapping: - """Colormap and normaliser for this scale, fitted to `data` where the scale is relative.""" - - @abstractmethod - def _definition(self) -> Dict[str, Any]: - """Subclass-specific fields for to_dict.""" - - def to_dict(self) -> Dict[str, Any]: - return {"type": self.kind, "name": self.name, "units": self.units, - "description": self.description, "group": self.group, **self._definition()} - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "ColourScale": - kind = data.get("type") - scale_cls = ColourScale._registry.get(kind) - if scale_cls is None: - raise ColourScaleError(f"Unknown colour scale type '{kind}'.") - return scale_cls._from_definition(data) - - @classmethod - @abstractmethod - def _from_definition(cls, data: Dict[str, Any]) -> "ColourScale": - """Build from a dict written by to_dict.""" - - def preview_data(self) -> np.ndarray: - """Representative values spanning the scale, for swatches without real data.""" - return np.linspace(0.0, 1.0, 11) - - def to_colormap(self, data: Optional[Iterable] = None, samples: int = 256) -> Colormap: - """A plain 0-1 colormap that looks like this scale across its range (band widths kept), - for widgets that only take a Colormap. Plots should use `mapping`, which keeps data units.""" - mapping = self.mapping(data if data is not None else self.preview_data()) - values = np.linspace(mapping.norm.vmin, mapping.norm.vmax, samples) - return ListedColormap(mapping.cmap(mapping.norm(values)), name=self.name) - - def __repr__(self) -> str: - return f"{type(self).__name__}({self.name!r}, set={self.group!r})" - - -class GradientScale(ColourScale): - """A continuous gradient through colour stops. - - Stop positions are either absolute data values, or relative (0 = data minimum, - 1 = data maximum) so the gradient stretches to whatever data is passed in. - """ - - kind = "gradient" - - def __init__(self, name: str, stops: Sequence[Tuple[float, str]], relative: bool = True, - bins: Optional[int] = None, **kwargs): - super().__init__(name, **kwargs) - if len(stops) < 2: - raise ColourScaleError("A gradient needs at least 2 colour stops.") - self.stops = sorted(((float(p), _hex(c)) for p, c in stops), key=lambda s: s[0]) - self.relative = bool(relative) - self.bins = bins # None for smooth, or a number of discrete steps - positions = [p for p, _ in self.stops] - if positions[0] == positions[-1]: - raise ColourScaleError("Gradient stops must span a range.") - if self.relative and (positions[0] < 0 or positions[-1] > 1): - raise ColourScaleError("Relative stop positions must lie between 0 and 1.") - - @classmethod - def from_palette(cls, name: str, palette: str, **kwargs) -> "GradientScale": - if palette not in PALETTES: - raise ColourScaleError(f"Unknown palette '{palette}'.") - colours = PALETTES[palette] - n = len(colours) - return cls(name, [(i / (n - 1), c) for i, c in enumerate(colours)], relative=True, **kwargs) - - def value_range(self, data: Optional[Iterable] = None) -> Tuple[float, float]: - if not self.relative: - return self.stops[0][0], self.stops[-1][0] - values = clean_data(data) - if values.size == 0: - return 0.0, 1.0 - lo, hi = float(values.min()), float(values.max()) - return (lo - 0.5, hi + 0.5) if lo == hi else (lo, hi) - - def mapping(self, data: Optional[Iterable] = None) -> ScaleMapping: - vmin, vmax = self.value_range(data) - first, last = self.stops[0][0], self.stops[-1][0] - nodes = [((p - first) / (last - first), c) for p, c in self.stops] - cmap = LinearSegmentedColormap.from_list(self.name, nodes) - if self.bins: - cmap = cmap.resampled(int(self.bins)) - edges = list(np.linspace(vmin, vmax, int(self.bins) + 1)) - norm = BoundaryNorm(edges, ncolors=int(self.bins), clip=True) - ticks = edges - else: - norm = Normalize(vmin=vmin, vmax=vmax, clip=True) - ticks = list(np.linspace(vmin, vmax, 6)) - return ScaleMapping(cmap, norm, ticks, [_fmt(t) for t in ticks]) - - def _definition(self) -> Dict[str, Any]: - return {"stops": [[p, c] for p, c in self.stops], "relative": self.relative, "bins": self.bins} - - @classmethod - def _from_definition(cls, data: Dict[str, Any]) -> "GradientScale": - return cls(data["name"], [tuple(s) for s in data["stops"]], relative=data.get("relative", True), - bins=data.get("bins"), units=data.get("units"), description=data.get("description", ""), - group=data.get("group") or DEFAULT_SET) - - -class ThresholdScale(ColourScale): - """Bands with fixed boundaries in data units: one colour (and optional label) per band. - - `thresholds` are the inner boundaries; values below the first threshold take the first - colour and values at or above the last take the last, so the bands hold for any data. - """ - - kind = "thresholds" - - def __init__(self, name: str, thresholds: Sequence[float], colours: Sequence[str], - labels: Optional[Sequence[Optional[str]]] = None, **kwargs): - super().__init__(name, **kwargs) - self.thresholds = [float(t) for t in thresholds] - self.colours = [_hex(c) for c in colours] - if not self.thresholds: - raise ColourScaleError("A threshold scale needs at least one threshold.") - if any(b <= a for a, b in zip(self.thresholds, self.thresholds[1:])): - raise ColourScaleError("Thresholds must be strictly increasing.") - if len(self.colours) != len(self.thresholds) + 1: - raise ColourScaleError( - f"{len(self.thresholds)} threshold(s) make {len(self.thresholds) + 1} bands, " - f"but {len(self.colours)} colour(s) were given.") - if labels is not None and len(labels) != len(self.colours): - raise ColourScaleError(f"Give one label per band ({len(self.colours)}), or none.") - self.labels = [None if not l else str(l) for l in labels] if labels is not None else None - - @classmethod - def from_data(cls, name: str, data: Iterable, bands: int = 5, method: str = "nice", - palette: str = "Viridis (colour-blind safe)", **kwargs) -> "ThresholdScale": - """Suggest thresholds from the data passed in, coloured from a preset palette.""" - thresholds = suggest_thresholds(data, bands, method) - return cls(name, thresholds, palette_colours(palette, len(thresholds) + 1), **kwargs) - - @property - def bands(self) -> int: - return len(self.colours) - - def band_ranges(self) -> List[Tuple[Optional[float], Optional[float]]]: - """(lower, upper) per band; None for the open ends.""" - edges: List[Optional[float]] = [None] + list(self.thresholds) + [None] - return list(zip(edges[:-1], edges[1:])) - - def band_labels(self) -> List[str]: - """Labels as given, else ranges such as '< 18', '18 - 21', '>= 24'.""" - auto = [] - for lower, upper in self.band_ranges(): - if lower is None: - auto.append(f"< {_fmt(upper)}") - elif upper is None: - auto.append(f">= {_fmt(lower)}") - else: - auto.append(f"{_fmt(lower)} - {_fmt(upper)}") - if self.labels is None: - return auto - return [given or default for given, default in zip(self.labels, auto)] - - def band_of(self, value: float) -> int: - """Index of the band a value falls in.""" - return int(np.searchsorted(self.thresholds, float(value), side="right")) - - def mapping(self, data: Optional[Iterable] = None) -> ScaleMapping: - values = clean_data(data) - span = self.thresholds[-1] - self.thresholds[0] or 1.0 - lo = min(self.thresholds[0] - span / max(self.bands - 2, 1), float(values.min()) if values.size else math.inf) - hi = max(self.thresholds[-1] + span / max(self.bands - 2, 1), float(values.max()) if values.size else -math.inf) - edges = [lo] + self.thresholds + [hi + 1e-9] - cmap = ListedColormap(self.colours, name=self.name) - norm = BoundaryNorm(edges, ncolors=self.bands, clip=True) - return ScaleMapping(cmap, norm, list(self.thresholds), [_fmt(t) for t in self.thresholds], - band_labels=self.band_labels()) - - def preview_data(self) -> np.ndarray: - span = self.thresholds[-1] - self.thresholds[0] or 1.0 - pad = span / max(self.bands - 2, 1) - return np.linspace(self.thresholds[0] - pad, self.thresholds[-1] + pad, 101) - - def _definition(self) -> Dict[str, Any]: - return {"thresholds": self.thresholds, "colours": self.colours, "labels": self.labels} - - @classmethod - def _from_definition(cls, data: Dict[str, Any]) -> "ThresholdScale": - return cls(data["name"], data["thresholds"], data["colours"], labels=data.get("labels"), - units=data.get("units"), description=data.get("description", ""), - group=data.get("group") or DEFAULT_SET) + start = max(len(inner) - wanted, 0) // 2 + return inner[start:start + wanted] # --------------------------------------------------------------------------- -# Library: saved scales grouped in user-defined sets +# Saved scales: one JSON file, one entry per name # --------------------------------------------------------------------------- -class ColourScaleLibrary: - """Saved colour scales, grouped into named sets, stored as JSON. - - Entries that cannot be read are kept on disk, reported in `problems` and logged, - never silently dropped. - """ - - def __init__(self, path: Optional[Path] = None): - self.path = Path(path) if path is not None else LIBRARY_FILE - self.problems: List[str] = [] - self._scales: Dict[str, ColourScale] = {} - self._unreadable: List[Dict[str, Any]] = [] - self.reload() - - def reload(self) -> None: - self._scales.clear() - self._unreadable.clear() - self.problems = [] - if not self.path.exists(): - return +def load_scales(path: Optional[Path] = None) -> List[ColourScale]: + """Saved scales; entries that cannot be read are logged and skipped (never deleted from the file).""" + scales = [] + for entry in _read(path): try: - raw = json.loads(self.path.read_text(encoding="utf-8")) - except (OSError, ValueError) as e: - raise ColourScaleError(f"Cannot read the colour scale library {self.path}: {e}") from e - - entries = raw.get("scales", []) if isinstance(raw, dict) else raw - if not isinstance(entries, list): - raise ColourScaleError(f"{self.path} is not a colour scale library.") - for entry in entries: - try: - scale = ColourScale.from_dict(entry) - except (KeyError, TypeError, ValueError) as e: - name = entry.get("name", "?") if isinstance(entry, dict) else "?" - self.problems.append(f"Could not read saved scale '{name}': {e}") - self._unreadable.append(entry) - continue - self._scales[scale.name] = scale - for problem in self.problems: - CONSOLE_LOGGER.warning(problem) - - # -- queries ------------------------------------------------------------ - - def names(self, group: Optional[str] = None) -> List[str]: - return [s.name for s in self._scales.values() if group is None or s.group == group] - - def groups(self) -> List[str]: - """The user-defined sets, in first-seen order.""" - return list(dict.fromkeys(s.group for s in self._scales.values())) - - def scales(self, group: Optional[str] = None) -> List[ColourScale]: - return [s for s in self._scales.values() if group is None or s.group == group] - - def get(self, name: str) -> ColourScale: - if name not in self._scales: - raise KeyError(f"No saved colour scale named '{name}'.") - return self._scales[name] - - def __contains__(self, name: str) -> bool: - return name in self._scales - - def __len__(self) -> int: - return len(self._scales) - - # -- changes ------------------------------------------------------------ - - def save(self, scale: ColourScale, overwrite: bool = False) -> None: - if scale.name in self._scales and not overwrite: - raise ColourScaleError(f"A scale named '{scale.name}' already exists.") - self._scales[scale.name] = scale - self._write() - - def delete(self, name: str) -> None: - if name not in self._scales: - raise KeyError(f"No saved colour scale named '{name}'.") - del self._scales[name] - self._write() - - def rename_group(self, old: str, new: str) -> None: - for scale in self.scales(old): - scale.group = new - self._write() - - def delete_group(self, group: str) -> None: - for name in self.names(group): - del self._scales[name] - self._write() - - def clear(self) -> None: - self._scales.clear() - self._write() - - def _write(self) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) - payload = {"version": LIBRARY_VERSION, - "scales": [s.to_dict() for s in self._scales.values()] + self._unreadable} - tmp = self.path.with_suffix(".tmp") - tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8") - tmp.replace(self.path) + scales.append(ColourScale.from_dict(entry)) + except (KeyError, TypeError, ValueError) as e: + CONSOLE_LOGGER.warning(f"Could not read saved scale '{entry.get('name', '?')}': {e}") + return scales + + +def save_scale(scale: ColourScale, path: Optional[Path] = None, overwrite: bool = False) -> None: + """Add a scale to the saved file; an existing name is only replaced with `overwrite`.""" + path = Path(path or LIBRARY_FILE) + entries = _read(path) + others = [e for e in entries if e.get("name") != scale.name] + if len(others) < len(entries) and not overwrite: + raise ColourScaleError(f"A scale named '{scale.name}' already exists.") + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps({"version": 1, "scales": others + [scale.to_dict()]}, indent=2), encoding="utf-8") + tmp.replace(path) + + +def _read(path: Optional[Path]) -> List[Dict[str, Any]]: + path = Path(path or LIBRARY_FILE) + if not path.exists(): + return [] + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + raise ColourScaleError(f"Cannot read the colour scale library {path}: {e}") from e + entries = raw.get("scales") if isinstance(raw, dict) else raw + if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries): + raise ColourScaleError(f"{path} is not a colour scale library.") + return entries # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -_INVALID_NAME_CHARS = set(r'\/:*?"<>|') - - -def validate_name(name: str) -> str: - name = (name or "").strip() - if not name: - raise ColourScaleError("A colour scale needs a name.") - if any(c in _INVALID_NAME_CHARS for c in name): - raise ColourScaleError("Names cannot contain \\ / : * ? \" < > |") - return name +def clean_data(data: Optional[Iterable]) -> np.ndarray: + """Finite float values from any iterable; None, NaN and non-numbers are dropped.""" + if data is None: + return np.empty(0) + numbers = np.array([_to_float(v) for v in np.ravel(np.asarray(list(data), dtype=object))], dtype=float) + return numbers[np.isfinite(numbers)] def _hex(colour: str) -> str: @@ -566,6 +251,3 @@ def _to_float(value) -> float: def _fmt(value: float) -> str: return f"{value:.4g}" - - -register_palette_cmaps() diff --git a/Python_Engine/Python/src/python_toolkit/plot/palettes.py b/Python_Engine/Python/src/python_toolkit/plot/palettes.py new file mode 100644 index 0000000..160b02b --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/plot/palettes.py @@ -0,0 +1,66 @@ +"""Colour palettes: matplotlib's colour maps plus the toolkit's own, always registered. + +Importing this module adds the toolkit palettes (and their reversed `_r` copies) to +matplotlib, so any palette name works anywhere a colour map name is accepted: + + palettes.names("colourblind") # ['viridis', 'cividis', ..., 'okabe_ito'] + palettes.sample("okabe_ito", 4) # four hex colours +""" + +from typing import List, Optional + +import numpy as np +from matplotlib import colormaps +from matplotlib.colors import LinearSegmentedColormap, ListedColormap, to_hex + +# The toolkit's own palettes: name -> (kind, colour-blind safe, colours). +CUSTOM = { + "okabe_ito": ("categorical", True, ["#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"]), + "cool_to_warm": ("continuous", False, ["#3b4cc0", "#8db0fe", "#dddddd", "#f4987a", "#b40426"]), + "traffic_light": ("continuous", False, ["#1a9850", "#fee08b", "#d73027"]), + "ltc_heatmap": ("continuous", False, ["#000004", "#51127c", "#b63679", "#fb8861", "#fcffa4"]), + "ltc_maya": ("continuous", False, ["#5c8286", "#a1b18c", "#e7cca6", "#d98f79", "#b35b5a"]), + "ltc_paloma": ("continuous", False, ["#1a2a40", "#456a73", "#82a69d", "#d9ccb6", "#bf8865"]), +} + +# matplotlib colour maps by kind; the toolkit palettes are added below. +KINDS = { + "categorical": ["Accent", "Dark2", "Paired", "Pastel1", "Pastel2", "Set1", "Set2", "Set3", + "tab10", "tab20", "tab20b", "tab20c"], + "continuous": ["viridis", "plasma", "inferno", "magma", "cividis", "turbo", "Blues", "Greens", "Greys", + "Oranges", "Purples", "Reds", "YlGn", "YlGnBu", "YlOrBr", "YlOrRd", "coolwarm", "seismic", + "Spectral", "RdYlBu", "RdYlGn", "twilight", "twilight_shifted", "hsv"], + # perceptually uniform maps; matplotlib's own, so safe for the common colour vision deficiencies + "colourblind": ["viridis", "cividis", "plasma", "inferno", "magma"], +} + +for _name, (_kind, _safe, _colours) in CUSTOM.items(): + if _name not in colormaps: + _cmap = (ListedColormap(_colours, name=_name) if _kind == "categorical" + else LinearSegmentedColormap.from_list(_name, _colours)) + colormaps.register(_cmap, name=_name) + colormaps.register(_cmap.reversed(), name=f"{_name}_r") + KINDS[_kind].append(_name) + if _safe: + KINDS["colourblind"].append(_name) + + +def names(kind: Optional[str] = None) -> List[str]: + """Colour map names of a kind ("categorical", "continuous", "colourblind"), without reversed copies. + + With no kind, every registered colour map including the reversed `_r` copies. + """ + if kind is None: + return sorted(colormaps, key=str.lower) + return [name for name in KINDS[kind] if name in colormaps] + + +def sample(name: str, count: int) -> List[str]: + """`count` hex colours from a palette: the first ones for a categorical palette, else evenly spread.""" + cmap = colormaps[name] + base = name[:-2] if name.endswith("_r") else name + if base in KINDS["categorical"] and count <= cmap.N: + points = (np.arange(count) + 0.5) / cmap.N + else: + points = np.linspace(0, 1, count) if count > 1 else [0.5] + return [to_hex(cmap(p)) for p in points] diff --git a/Python_Engine/Python/tests/test_colour_scale_builder.py b/Python_Engine/Python/tests/test_colour_scale_builder.py index 5a80508..40dcbb7 100644 --- a/Python_Engine/Python/tests/test_colour_scale_builder.py +++ b/Python_Engine/Python/tests/test_colour_scale_builder.py @@ -1,11 +1,13 @@ import numpy as np import pytest +from matplotlib.colors import to_hex from python_toolkit.bhom_tkinter.bhom_base_window import BHoMBaseWindow from python_toolkit.bhom_tkinter.widgets.cmap_selector import CmapSelector from python_toolkit.bhom_tkinter.windows import ColourScaleBuilder -from python_toolkit.bhom_tkinter.windows.colour_scale_builder import GRADIENT, preview_figure -from python_toolkit.plot.colour_scales import ColourScaleLibrary, DataSummary, GradientScale, ThresholdScale +from python_toolkit.bhom_tkinter.windows.colour_scale_builder import preview_figure +from python_toolkit.plot import palettes +from python_toolkit.plot.colour_scales import ColourScale, load_scales, save_scale @pytest.fixture(scope="module") @@ -40,43 +42,40 @@ def run(self): @pytest.fixture def library(tmp_path): - return ColourScaleLibrary(tmp_path / "scales.json") + return tmp_path / "scales.json" def test_builder_suggests_bands_from_data(host, library): - builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), units="degC", library=library) + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), units="degC", library_path=library) scale = builder.current_scale() - assert isinstance(scale, ThresholdScale) assert scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) - assert scale.units == "degC" + assert scale.units == "degC" and scale.cmap == "viridis" assert "200 values from 16 to 30 degC" in builder.data_text.get() -def test_builder_saves_labels_into_a_set(host, library): +def test_builder_saves_labels(host, library): saved = [] - builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library=library, on_save=saved.append) + builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library_path=library, on_save=saved.append) builder.name.set("Comfort") - builder.group.value_var.set("Overheating") # typed into the editable set box builder._band_rows[0]["label"].set("Cold") builder._save() - reopened = ColourScaleLibrary(library.path) - scale = reopened.get("Comfort") + scale = load_scales(library)[0] assert saved and saved[0].name == "Comfort" - assert scale.group == "Overheating" and scale.band_labels()[0] == "Cold" + assert scale.name == "Comfort" and scale.band_labels()[0] == "Cold" def test_builder_reports_bad_thresholds_without_saving(host, library, warnings_shown): - builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library=library) + builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library_path=library) builder._band_rows[0]["upper"].set("warm") builder._save() assert builder.saved_scale is None and "not a number" in builder.status.get() assert warnings_shown and "not a number" in warnings_shown[0][0] # shown in a pop-up, not only in the window - assert len(library) == 0 + assert load_scales(library) == [] def test_builder_name_clash_is_explained_and_default_names_are_free(host, library, warnings_shown): - library.save(ThresholdScale("New scale", [1, 2], ["#000000", "#808080", "#ffffff"])) - builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library=library) + save_scale(ColourScale("New scale", [1, 2], "viridis"), library) + builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library_path=library) assert builder.name.get() == "New scale 2" builder.name.set("New scale") builder._save() @@ -86,51 +85,181 @@ def test_builder_name_clash_is_explained_and_default_names_are_free(host, librar assert builder.status.get() == "" # the old error does not linger -def test_builder_gradient_and_edit(host, library): - builder = ColourScaleBuilder(host, data=[0, 1, 2], library=library) - builder.kind.set(GRADIENT) - builder._on_kind_changed() - assert isinstance(builder.current_scale(), GradientScale) - existing = ThresholdScale("Existing", [1, 2], ["#000000", "#808080", "#ffffff"], labels=["a", "b", "c"]) - editor = ColourScaleBuilder(host, scale=existing, library=library) +def test_builder_log_breaks_and_edit(host, library): + builder = ColourScaleBuilder(host, data=np.geomspace(1, 1000, 100), library_path=library) + builder.method.set("log") + builder.band_count.set(3) + builder._suggest() + assert builder.current_scale().thresholds == pytest.approx([10, 100]) + existing = ColourScale("Existing", [1, 2], ["#000000", "#808080", "#ffffff"], labels=["a", "b", "c"]) + editor = ColourScaleBuilder(host, scale=existing, library_path=library) assert editor.current_scale().band_labels() == ["a", "b", "c"] + assert editor.current_scale().cmap == ["#000000", "#808080", "#ffffff"] def test_preview_with_and_without_data(): - scale = ThresholdScale("T", [20, 25], ["#0000ff", "#00ff00", "#ff0000"]) - values = np.linspace(16, 30, 50) - assert preview_figure(scale, values, DataSummary.from_data(values)).axes - assert preview_figure(scale, np.array([]), None).axes + scale = ColourScale("T", [20, 25], ["#0000ff", "#00ff00", "#ff0000"]) + assert preview_figure(scale, np.linspace(16, 30, 50)).axes + assert preview_figure(scale, np.array([])).axes -def test_builder_starts_from_a_colour_map_and_recolours(host, library): - from python_toolkit.plot.colour_scales import palette_colours - - builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 50), library=library, palette="magma") +def test_builder_starts_from_a_palette_and_recolours(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 50), library_path=library, palette="magma") assert builder.palette.get() == "magma" - assert [r["colour"].get() for r in builder._band_rows] == palette_colours("magma", 5) - builder.palette.set("Traffic light") + assert [r["colour"].get() for r in builder._band_rows] == palettes.sample("magma", 5) + assert builder.current_scale().cmap == "magma" # untouched band colours stay a palette reference + builder.palette.set("traffic_light") builder._recolour() scale = builder.current_scale() - assert scale.colours == palette_colours("Traffic light", 5) and scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) + assert scale.cmap == "traffic_light" and scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) + builder._band_rows[0]["colour"].set("#123456") + assert builder.current_scale().cmap[0] == "#123456" # a hand-picked colour makes it an explicit list -def test_cmap_selector_lists_saved_scales_and_adds_new_ones(host, tmp_path, monkeypatch): - from python_toolkit.plot import colour_scales +def test_cmap_selector_lists_palette_sets(host): + for cmap_set, expected in (("categorical", "okabe_ito"), ("colourblind", "okabe_ito"), ("continuous", "ltc_maya")): + values = list(CmapSelector(host.content_frame, cmap_set=cmap_set).cmap_combobox["values"]) + assert expected in values and f"{expected}_r" in values + assert "okabe_ito" not in CmapSelector(host.content_frame, cmap_set="continuous").cmap_combobox["values"] - monkeypatch.setattr(colour_scales, "LIBRARY_FILE", tmp_path / "scales.json") - ColourScaleLibrary().save(ThresholdScale("Comfort", [18, 24], ["#0000ff", "#00ff00", "#ff0000"])) - selector = CmapSelector(host.content_frame, colormaps=["viridis"], allow_custom=True) - assert list(selector.cmap_combobox["values"]) == ["Comfort", "viridis"] - assert selector.get_cmap().name == "Comfort" - changes = [] - selector.on_change = changes.append - selector._on_scale_saved(GradientScale("Warm", [(0, "#ffffff"), (1, "#ff0000")])) - assert list(selector.cmap_combobox["values"]) == ["Warm", "Comfort", "viridis"] - assert selector.get_cmap().name == "Warm" and len(changes) == 1 +def test_cmap_selector_lists_what_it_is_given(host): + selector = CmapSelector(host.content_frame, colormaps=["viridis"]) + assert list(selector.cmap_combobox["values"]) == ["viridis"] -def test_cmap_selector_without_custom_has_no_button(host): - selector = CmapSelector(host.content_frame, colormaps=["viridis"]) - assert list(selector.cmap_combobox["values"]) == ["viridis"] and not hasattr(selector, "new_scale_button") +def test_end_to_end_palette_to_saved_scale_to_colours(host, library): + """Pick a colour-blind palette in the selector, build a scale from data, save it, reload it and colour with it.""" + data = np.linspace(16, 30, 100) + selector = CmapSelector(host.content_frame, cmap_set="colourblind", default_cmap="okabe_ito") + assert selector.get() == "okabe_ito" + + builder = ColourScaleBuilder(host, data=data, library_path=library, palette=selector.get()) + builder.name.set("Comfort") + builder._save() + + scale = load_scales(library)[0] + assert scale.cmap == "okabe_ito" and scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) + cmap, norm = scale.mapping() + colours = {to_hex(cmap(norm(v))) for v in data} + assert colours == set(palettes.sample("okabe_ito", 5)) # every band is used, in the palette's colours + + scale.cmap = "viridis" # swap the colours; the breaks hold + assert scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) and scale.colours() == palettes.sample("viridis", 5) + + +def test_bands_and_breaks_update_the_scale_without_a_button(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) + assert len(builder._band_rows) == 5 + builder.band_count.set(3) # what the spinbox does when changed + builder._run_auto_suggest() # what fires once typing has paused + assert len(builder._band_rows) == 3 and builder.current_scale().thresholds == pytest.approx([20, 25]) + builder.method.set("equal") + builder.method.combobox.event_generate("<>") + builder._run_auto_suggest() + assert builder.current_scale().thresholds == pytest.approx([20.6667, 25.3333], abs=1e-3) + assert len(builder._band_rows) == 3 and builder.band_count.get() == 3 # showing the count does not loop + + +def test_typing_in_bands_waits_for_the_pause(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) + builder.band_count.set("1") # first keystroke of "12": nothing happens yet + assert len(builder._band_rows) == 5 and builder._suggest_job is not None + builder.band_count.set("12") + builder._run_auto_suggest() + assert len(builder._band_rows) == 12 + builder.band_count.set("99") # out of range: ignored, bands stay as they are + builder._run_auto_suggest() + assert len(builder._band_rows) == 12 + + +def test_bands_and_breaks_are_locked_without_data(host, library): + builder = ColourScaleBuilder(host, library_path=library) + assert str(builder.band_count.spinbox.cget("state")) == "disabled" + assert str(builder.method.combobox.cget("state")) == "disabled" + + +def test_changing_the_bands_keeps_what_was_filled_in(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) + for i, name in enumerate(["Cold", "Cool", "Comfy", "Warm"]): + builder._band_rows[i]["label"].set(name) + builder._band_rows[1]["colour"].set("#123456") + builder.band_count.set(4) + builder._run_auto_suggest() + scale = builder.current_scale() + assert scale.bands == 4 and scale.labels == ["Cold", "Cool", "Comfy", "Warm"] + assert scale.colours()[1] == "#123456" # the hand-picked colour stays + builder.band_count.set(6) # new bands start empty and take palette colours + builder._run_auto_suggest() + scale = builder.current_scale() + assert scale.labels[:4] == ["Cold", "Cool", "Comfy", "Warm"] and scale.labels[4:] == [None, None] + assert scale.colours()[1] == "#123456" and scale.colours()[5] == palettes.sample("viridis", 6)[5] + + +def test_changing_the_bands_recolours_untouched_colours(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) + builder.band_count.set(3) + builder._run_auto_suggest() + assert builder.current_scale().cmap == "viridis" # still a palette reference, resampled for 3 bands + + +def test_thresholds_spin_in_round_steps(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) + spin = builder._band_rows[0]["upper"].spinbox + assert float(spin.cget("increment")) == 0.1 # about 1% of the 14-wide range, rounded + for _ in range(5): + spin.event_generate("<>") + assert builder.current_scale().thresholds[0] == pytest.approx(17.5 + 0.5) + + +def test_split_a_band_adds_one_between_its_ends(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) # breaks 17.5 20 22.5 25 + builder._band_rows[1]["label"].set("Cool") + builder._split_band(1) # the band 17.5 to 20 becomes 17.5 to 18.75 and 18.75 to 20 + scale = builder.current_scale() + assert scale.thresholds == pytest.approx([17.5, 18.75, 20, 22.5, 25]) and scale.bands == 6 + assert scale.labels == [None, "Cool", None, None, None, None] # the label stays with the lower half + assert builder.band_count.get() == 6 + assert scale.cmap == "viridis" # still a palette, spread over six bands + + +def test_split_the_open_ends_against_the_data(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) + builder._split_band(0) # below 17.5: split against the data minimum, 16 + assert builder.current_scale().thresholds[:2] == pytest.approx([16.75, 17.5]) + builder._split_band(len(builder._band_rows) - 1) # 25 and above: split against the data maximum, 30 + assert builder.current_scale().thresholds[-1] == pytest.approx(27.5) + + +def test_remove_a_band_merges_its_range_into_the_next(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) + for i, name in enumerate(["a", "b", "c", "d", "e"]): + builder._band_rows[i]["label"].set(name) + builder._band_rows[2]["colour"].set("#123456") # a hand-picked colour, which makes colours explicit + builder._remove_band(1) + scale = builder.current_scale() + assert scale.thresholds == pytest.approx([17.5, 22.5, 25]) and scale.labels == ["a", "c", "d", "e"] # "c" now starts at 17.5 + assert scale.colours()[1] == "#123456" + builder._remove_band(len(builder._band_rows) - 1) # the last band: the one before it becomes open-ended + assert builder.current_scale().thresholds == pytest.approx([17.5, 22.5]) and builder.current_scale().labels == ["a", "c", "d"] + + +def test_bands_cannot_go_below_two_or_above_the_maximum(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) + builder.band_count.set(2) + builder._run_auto_suggest() + assert "disabled" in builder._band_rows[0]["remove"].state() + builder._remove_band(0) + assert len(builder._band_rows) == 2 + builder.band_count.set(12) + builder._run_auto_suggest() + assert "disabled" in builder._band_rows[0]["split"].state() + builder._split_band(0) + assert len(builder._band_rows) == 12 + + +def test_split_refuses_a_bad_threshold_without_changing_the_table(host, library): + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) + builder._band_rows[0]["upper"].set("warm") + builder._split_band(1) + assert len(builder._band_rows) == 5 and "not a number" in builder.status.get() diff --git a/Python_Engine/Python/tests/test_colour_scales.py b/Python_Engine/Python/tests/test_colour_scales.py index 843ec51..247b8d2 100644 --- a/Python_Engine/Python/tests/test_colour_scales.py +++ b/Python_Engine/Python/tests/test_colour_scales.py @@ -2,88 +2,96 @@ import numpy as np import pytest +from matplotlib import colormaps from matplotlib.colors import to_hex +from python_toolkit.plot import palettes from python_toolkit.plot.colour_scales import ( ColourScale, ColourScaleError, - ColourScaleLibrary, - DataSummary, - GradientScale, - PALETTES, - ThresholdScale, - palette_colours, + load_scales, + save_scale, suggest_thresholds, ) -COMFORT = dict(thresholds=[18, 21, 24], colours=["#0000ff", "#00ffff", "#00ff00", "#ff0000"], - labels=["Cold", "Cool", "Comfortable", "Hot"], units="degC", group="TM59") +COMFORT = dict(thresholds=[18, 21, 24], cmap=["#0000ff", "#00ffff", "#00ff00", "#ff0000"], + labels=["Cold", "Cool", "Comfortable", "Hot"], units="degC") -def colour_of(mapping, value): - return to_hex(mapping.cmap(mapping.norm(value))) +def colour_of(scale, value): + cmap, norm = scale.mapping() + return to_hex(cmap(norm(value))) -# -- thresholds hold in data units, whatever data is passed in ---------------- +# -- palettes ------------------------------------------------------------------ -@pytest.mark.parametrize("data", [[19, 20, 22], np.linspace(-50, 200, 30), [23.9], None]) -def test_bands_fixed_for_any_data(data): - mapping = ThresholdScale("Comfort", **COMFORT).mapping(data) - assert colour_of(mapping, 17.99) == "#0000ff" - assert colour_of(mapping, 18.0) == "#00ffff" # boundary belongs to the band above - assert colour_of(mapping, 22.0) == "#00ff00" - assert colour_of(mapping, 24.0) == "#ff0000" - assert colour_of(mapping, -1000) == "#0000ff" and colour_of(mapping, 1000) == "#ff0000" - assert mapping.band_labels == ["Cold", "Cool", "Comfortable", "Hot"] +def test_toolkit_palettes_are_always_registered(): + for name, (kind, _safe, _colours) in palettes.CUSTOM.items(): + assert name in colormaps and f"{name}_r" in colormaps + assert name in palettes.names(kind) + assert isinstance(colormaps["okabe_ito"], type(colormaps["Set1"])) # discrete, like the other categorical maps + assert "okabe_ito" in palettes.names("colourblind") and "ltc_maya" not in palettes.names("colourblind") + assert "viridis_r" in palettes.names() and "viridis_r" not in palettes.names("continuous") -def test_auto_band_labels_and_band_of(): - scale = ThresholdScale("Plain", [10, 20], ["red", "green", "blue"]) - assert scale.band_labels() == ["< 10", "10 - 20", ">= 20"] - assert [scale.band_of(v) for v in (5, 10, 19.9, 20, 99)] == [0, 1, 1, 2, 2] - partly = ThresholdScale("Partly", [10, 20], ["red", "green", "blue"], labels=["Low", None, ""]) - assert partly.band_labels() == ["Low", "10 - 20", ">= 20"] +def test_sample_takes_the_first_colours_of_a_categorical_palette(): + assert palettes.sample("okabe_ito", 3) == ["#e69f00", "#56b4e9", "#009e73"] + assert palettes.sample("magma", 3)[0] == "#000004" + assert len(palettes.sample("traffic_light", 7)) == 7 and len(palettes.sample("viridis", 1)) == 1 -@pytest.mark.parametrize("kwargs,message", [ - (dict(thresholds=[], colours=["red"]), "at least one"), - (dict(thresholds=[2, 1], colours=["r", "g", "b"]), "increasing"), - (dict(thresholds=[1, 2], colours=["red", "green"]), "3 bands"), - (dict(thresholds=[1], colours=["red", "green"], labels=["a"]), "one label per band"), - (dict(thresholds=[1], colours=["red", "notacolour"]), "not a colour"), -]) -def test_threshold_validation(kwargs, message): - with pytest.raises(ColourScaleError, match=message): - ThresholdScale("Bad", **kwargs) +# -- thresholds hold in data units, whatever data is passed in ---------------- +def test_bands_hold_in_data_units(): + scale = ColourScale("Comfort", **COMFORT) + assert colour_of(scale, 17.99) == "#0000ff" + assert colour_of(scale, 18.0) == "#00ffff" # boundary belongs to the band above + assert colour_of(scale, 22.0) == "#00ff00" + assert colour_of(scale, 24.0) == "#ff0000" + assert colour_of(scale, -1000) == "#0000ff" and colour_of(scale, 1000) == "#ff0000" -def test_gradient_relative_stretches_to_data_absolute_does_not(): - relative = GradientScale.from_palette("Rel", "Traffic light") - assert relative.mapping([10, 30]).norm.vmin == 10 and relative.mapping([10, 30]).norm.vmax == 30 - absolute = GradientScale("Abs", [(0, "blue"), (100, "red")], relative=False) - assert (absolute.mapping([10, 30]).norm.vmin, absolute.mapping([10, 30]).norm.vmax) == (0, 100) - stepped = GradientScale("Steps", [(0, "blue"), (1, "red")], bins=4).mapping([0, 8]) - assert len({colour_of(stepped, v) for v in np.linspace(0, 8, 50)}) == 4 - with pytest.raises(ColourScaleError): - GradientScale("Bad", [(0, "red"), (2, "blue")], relative=True) +def test_band_labels(): + assert ColourScale("Comfort", **COMFORT).band_labels() == ["Cold", "Cool", "Comfortable", "Hot"] + plain = ColourScale("Plain", [10, 20], "viridis") + assert plain.band_labels() == ["< 10", "10 - 20", ">= 20"] + assert ColourScale("Partly", [10, 20], "viridis", labels=["Low", None, ""]).band_labels() == ["Low", "10 - 20", ">= 20"] -def test_mapping_colours_for_handles_missing_values(): - rgba = ThresholdScale("C", **COMFORT).mapping().colours_for([17, None, "x", 30]) - assert np.isnan(rgba[1]).all() and np.isnan(rgba[2]).all() - assert to_hex(rgba[3]) == "#ff0000" +def test_changing_the_palette_keeps_the_breaks(): + scale = ColourScale("Heat", [10, 20, 30], "viridis") + assert scale.colours() == palettes.sample("viridis", 4) + scale.cmap = "okabe_ito" + assert scale.thresholds == [10, 20, 30] and scale.colours() == palettes.sample("okabe_ito", 4) + scale.cmap = ["red", "green", "blue", "black"] + assert scale.colours()[0] == "#ff0000" + with pytest.raises(ColourScaleError, match="4 bands"): + scale.cmap = ["red", "green"] -# -- data passed in ---------------------------------------------------------- -def test_data_summary_ignores_unreadable_values(): - summary = DataSummary.from_data([1, 2, None, "n/a", float("nan"), 3, 10]) - assert summary.count == 4 and summary.minimum == 1 and summary.maximum == 10 - assert summary.histogram[0].sum() == 4 - with pytest.raises(ColourScaleError): - DataSummary.from_data([None, "x"]) +def test_value_range_follows_the_data_or_the_thresholds(): + scale = ColourScale("T", [10, 20], "viridis") + assert scale.value_range([12, 18]) == (12, 18) and scale.value_range([5, 5]) == (4.5, 5.5) + lo, hi = scale.value_range() + assert lo < 10 and hi > 20 +@pytest.mark.parametrize("kwargs,message", [ + (dict(thresholds=[]), "at least one"), + (dict(thresholds=[2, 1]), "increasing"), + (dict(thresholds=[1, 2], cmap=["red", "green"]), "3 bands"), + (dict(thresholds=[1], labels=["a"]), "one label per band"), + (dict(thresholds=[1], cmap=["red", "notacolour"]), "not a colour"), + (dict(thresholds=[1], cmap="no_such_map"), "Unknown colour map"), + (dict(thresholds=[1], name=" "), "needs a name"), +]) +def test_validation(kwargs, message): + with pytest.raises(ColourScaleError, match=message): + ColourScale(**{"name": "Bad", **kwargs}) + + +# -- suggesting thresholds ----------------------------------------------------- + def test_suggest_thresholds_methods(): data = np.concatenate([np.full(90, 20.0), np.linspace(20, 30, 10)]) assert suggest_thresholds([0, 100], 4, "equal") == [25, 50, 75] @@ -92,6 +100,20 @@ def test_suggest_thresholds_methods(): suggest_thresholds(data, 4, "quantile") q = suggest_thresholds(np.arange(100), 4, "quantile") assert len(q) == 3 and q[0] < q[1] < q[2] + assert suggest_thresholds([1, None, "x", float("nan"), 3], 2, "equal") == [2] + + +def test_log_thresholds_have_equal_ratios(): + thresholds = suggest_thresholds([1, 1000], 3, "log") + assert thresholds == pytest.approx([10, 100]) + spread = suggest_thresholds(np.geomspace(0.5, 4000, 50), 5, "log") + ratios = [b / a for a, b in zip(spread, spread[1:])] + assert len(spread) == 4 and ratios == pytest.approx([ratios[0]] * 3, rel=0.02) + for data in ([0, 10, 100], [-5, 10, 100]): + with pytest.raises(ColourScaleError, match="above zero"): + suggest_thresholds(data, 3, "log") + with pytest.raises(ColourScaleError, match="too narrow"): + suggest_thresholds([1.0, 1.001], 5, "log") @pytest.mark.parametrize("data,bands,expected", [ @@ -106,79 +128,61 @@ def test_nice_thresholds_fall_inside_the_data(data, bands, expected): assert all(min(data) < t < max(data) for t in thresholds) -def test_threshold_scale_from_data(): - scale = ThresholdScale.from_data("Loads", np.linspace(0, 95, 50), bands=4, method="nice", - palette="Okabe-Ito (colour-blind safe)", group="Loads") - assert scale.thresholds == [25, 50, 75] and scale.bands == 4 and scale.group == "Loads" - assert len(palette_colours("Traffic light", 7)) == 7 +# -- saved scales -------------------------------------------------------------- +def test_round_trip_through_dict(): + for scale in (ColourScale("Comfort", **COMFORT), ColourScale("Palette", [1, 2], cmap="ltc_maya")): + assert ColourScale.from_dict(json.loads(json.dumps(scale.to_dict()))).to_dict() == scale.to_dict() -# -- persistence ------------------------------------------------------------- -def test_round_trip_through_dict(): - for scale in (ThresholdScale("Comfort", **COMFORT), GradientScale.from_palette("G", "LTC Maya", bins=6)): - rebuilt = ColourScale.from_dict(json.loads(json.dumps(scale.to_dict()))) - assert type(rebuilt) is type(scale) and rebuilt.to_dict() == scale.to_dict() - - -def test_library_sets(tmp_path): - library = ColourScaleLibrary(tmp_path / "scales.json") - library.save(ThresholdScale("Comfort", **COMFORT)) - library.save(GradientScale.from_palette("Heat", "LTC Heatmap", group="Loads")) - library.save(ThresholdScale("Overheating", [26, 28], ["green", "orange", "red"], group="TM59")) - reopened = ColourScaleLibrary(tmp_path / "scales.json") - assert reopened.groups() == ["TM59", "Loads"] - assert reopened.names("TM59") == ["Comfort", "Overheating"] - assert reopened.get("Comfort").labels[-1] == "Hot" +def test_save_and_load(tmp_path): + path = tmp_path / "scales.json" + save_scale(ColourScale("Comfort", **COMFORT), path) + save_scale(ColourScale("Overheating", [26, 28], "RdYlGn_r"), path) + assert [s.name for s in load_scales(path)] == ["Comfort", "Overheating"] with pytest.raises(ColourScaleError, match="already exists"): - reopened.save(ThresholdScale("Comfort", **COMFORT)) - reopened.rename_group("TM59", "Overheating checks") - reopened.delete_group("Loads") - final = ColourScaleLibrary(tmp_path / "scales.json") - assert final.groups() == ["Overheating checks"] and len(final) == 2 + save_scale(ColourScale("Comfort", [1], "viridis"), path) + save_scale(ColourScale("Comfort", [1], "viridis"), path, overwrite=True) + assert load_scales(path)[-1].thresholds == [1] -def test_unreadable_entries_reported_and_kept(tmp_path): +def test_unreadable_entries_are_skipped_and_kept(tmp_path): path = tmp_path / "mixed.json" - path.write_text(json.dumps({"version": 2, "scales": [ - ThresholdScale("Good", [1], ["red", "blue"]).to_dict(), - {"type": "thresholds", "name": "Broken", "thresholds": [2, 1], "colours": ["a"]}, + path.write_text(json.dumps({"scales": [ + ColourScale("Good", [1], "viridis").to_dict(), + {"name": "Broken", "thresholds": [2, 1], "cmap": "viridis"}, ]}), encoding="utf-8") - library = ColourScaleLibrary(path) - assert library.names() == ["Good"] and "Broken" in library.problems[0] - library.save(ThresholdScale("New", [5], ["red", "blue"])) - names_on_disk = [e["name"] for e in json.loads(path.read_text(encoding="utf-8"))["scales"]] - assert "Broken" in names_on_disk # never silently deleted + assert [s.name for s in load_scales(path)] == ["Good"] + save_scale(ColourScale("New", [5], "viridis"), path) + assert "Broken" in [e["name"] for e in json.loads(path.read_text(encoding="utf-8"))["scales"]] -def test_corrupt_library_raises(tmp_path): - path = tmp_path / "bad.json" - path.write_text("{not json", encoding="utf-8") - with pytest.raises(ColourScaleError, match="Cannot read"): - ColourScaleLibrary(path) +def test_entries_without_thresholds_are_skipped(tmp_path): + path = tmp_path / "gradient.json" + path.write_text(json.dumps({"scales": [{"type": "gradient", "name": "Old gradient", "stops": [[0, "red"], [1, "blue"]]}]}), + encoding="utf-8") + assert load_scales(path) == [] -def test_palettes_are_valid(): - for name, colours in PALETTES.items(): - assert len(colours) >= 3, name - GradientScale.from_palette(name, name) +def test_older_threshold_entries_still_load(tmp_path): + path = tmp_path / "old.json" + path.write_text(json.dumps({"version": 1, "scales": [ + {"type": "thresholds", "name": "Old", "thresholds": [1], "colours": ["#000000", "#ffffff"], + "labels": None, "units": None, "group": "TM59"}, + ]}), encoding="utf-8") + assert load_scales(path)[0].colours() == ["#000000", "#ffffff"] + +def test_corrupt_file_raises(tmp_path): + path = tmp_path / "bad.json" + path.write_text("{not json", encoding="utf-8") + with pytest.raises(ColourScaleError, match="Cannot read"): + load_scales(path) -def test_palettes_registered_as_colour_maps(): - from matplotlib import colormaps - from python_toolkit.plot.colour_scales import PALETTE_CMAPS, palette_names - for name in PALETTE_CMAPS.values(): - assert name in colormaps and f"{name}_r" in colormaps - names = palette_names() - assert names[:len(PALETTES)] == list(PALETTES) and "magma" in names and "okabe_ito" not in names - assert palette_colours("magma", 3)[0] == "#000004" - with pytest.raises(ColourScaleError, match="Unknown palette"): - palette_colours("no such map", 3) - - -def test_to_colormap_keeps_band_widths(): - scale = ThresholdScale("T", [10, 30], ["#0000ff", "#00ff00", "#ff0000"]) - cmap = scale.to_colormap(np.array([0, 40])) - colours = [to_hex(cmap(x)) for x in (0.1, 0.5, 0.9)] - assert colours == ["#0000ff", "#00ff00", "#ff0000"] and cmap.name == "T" +@pytest.mark.parametrize("method", ["equal", "quantile", "nice", "log"]) +@pytest.mark.parametrize("bands", [2, 3, 4, 5, 6, 8]) +def test_suggestions_always_give_the_number_of_bands_asked_for(method, bands): + data = np.random.default_rng(1).normal(22, 3, 300) + assert len(suggest_thresholds(data, bands, method)) == bands - 1 + assert len(suggest_thresholds([16, 30], bands, "nice")) == bands - 1 From ef4cb4d092e0fcee6a06c3c08ab39737d25d805d Mon Sep 17 00:00:00 2001 From: Felix Mallinder Date: Wed, 28 Oct 2026 17:43:15 +0000 Subject: [PATCH 3/3] improved builder objects --- .../windows/colour_scale_builder.py | 7 +- .../src/python_toolkit/plot/colour_scales.py | 66 ++++++++++++++++--- .../src/python_toolkit/plot/palettes.py | 3 + .../Python/tests/test_colour_scale_builder.py | 24 +++---- .../Python/tests/test_colour_scales.py | 31 +++++---- 5 files changed, 90 insertions(+), 41 deletions(-) diff --git a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py index 86dc896..bb74fea 100644 --- a/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py @@ -31,7 +31,7 @@ from python_toolkit.bhom_tkinter.widgets.spinbox import Spinbox from python_toolkit.bhom_tkinter.widgets.validated_entry_box import ValidatedEntryBox from python_toolkit.bhom_tkinter.theming.scaling import scale as scale_px -from python_toolkit.plot import palettes +from python_toolkit.plot import colour_scales, palettes from python_toolkit.plot.colour_scales import ( SUGGESTION_METHODS, ColourScale, @@ -39,10 +39,9 @@ clean_data, load_scales, save_scale, - suggest_thresholds, ) -MAX_BANDS = 12 +MAX_BANDS = 24 TYPING_DELAY = 700 # ms to wait after the last keystroke, so "22" is not read as "2" first PLOT_WIDTH = 560 # the plot, the controls and the band table all share this width ERROR_COLOUR = "#c0392b" @@ -206,7 +205,7 @@ def _run_auto_suggest(self) -> None: def _suggest(self) -> None: try: bands = int(self.band_count.get()) - thresholds = suggest_thresholds(self.values, bands, self.method.get()) + thresholds = colour_scales.thresholds(self.values, bands, self.method.get()) except (ColourScaleError, ValueError) as e: self.status.set(str(e)) return diff --git a/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py b/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py index 7d5d035..5244735 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py +++ b/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py @@ -29,7 +29,8 @@ from python_toolkit.plot import palettes LIBRARY_FILE = Path.home() / ".bhom" / "colour_scales.json" -SUGGESTION_METHODS = ("equal", "quantile", "nice", "log") +SUGGESTION_METHODS = ("equal", "quantile", "jenks", "nice", "log") +JENKS_MAX_VALUES = 2000 # larger data is thinned (evenly through its sorted values) before the search class ColourScaleError(ValueError): @@ -90,9 +91,15 @@ def value_range(self, data: Optional[Iterable] = None) -> Tuple[float, float]: lo, hi = self.thresholds[0] - pad, self.thresholds[-1] + pad return (lo - 0.5, hi + 0.5) if lo == hi else (lo, hi) - def mapping(self) -> Tuple[Colormap, BoundaryNorm]: - """The colormap and normaliser to colour any data with; values beyond the ends take the end colours.""" - edges = [self.thresholds[0] - 1] + self.thresholds + [self.thresholds[-1] + 1] + def mapping(self, data: Optional[Iterable] = None) -> Tuple[Colormap, BoundaryNorm]: + """The colormap and normaliser to colour any data with; values beyond the ends take the end colours. + + Pass the data so the end bands run to its minimum and maximum: a colour bar drawn with + `spacing="proportional"` then shows every band at its true width, as the builder preview does.""" + lo, hi = self.value_range(data) + t = self.thresholds + first_gap, last_gap = (t[1] - t[0], t[-1] - t[-2]) if len(t) > 1 else (1.0, 1.0) + edges = [lo if lo < t[0] else t[0] - first_gap] + t + [hi if hi > t[-1] else t[-1] + last_gap] return ListedColormap(self.colours(), name=self.name), BoundaryNorm(edges, self.bands, clip=True) def band_labels(self) -> List[str]: @@ -120,11 +127,12 @@ def __repr__(self) -> str: # Suggesting thresholds from data # --------------------------------------------------------------------------- -def suggest_thresholds(data: Iterable, bands: int, method: str = "nice") -> List[float]: +def thresholds(data: Iterable, bands: int, method: str = "nice") -> List[float]: """Inner boundaries splitting the data into `bands` bands. equal: equal-width bands across the data range quantile: roughly the same number of values in each band + jenks: natural breaks; the bands that minimise the spread of values within each band nice: equal-width bands on rounded 1 / 2 / 5 x 10^n steps log: bands of equal ratio (each break a fixed multiple of the last), rounded to 3 significant figures; needs every value above zero @@ -148,6 +156,8 @@ def suggest_thresholds(data: Iterable, bands: int, method: str = "nice") -> List if len(unique) < bands - 1: raise ColourScaleError("Too many repeated values for that many quantile bands; use fewer bands.") return unique + if method == "jenks": + return _jenks_thresholds(values, bands) if method == "log": if lo <= 0: raise ColourScaleError("Logarithmic breaks need every value above zero; use another break method.") @@ -158,11 +168,50 @@ def suggest_thresholds(data: Iterable, bands: int, method: str = "nice") -> List return _nice_thresholds(lo, hi, bands - 1) +def _jenks_thresholds(values: np.ndarray, bands: int) -> List[float]: + """Fisher-Jenks natural breaks: the split into `bands` runs of sorted values with the least + total squared deviation from each run's mean. Each break is the lowest value of the band above it.""" + ordered = np.sort(values) + if ordered.size > JENKS_MAX_VALUES: + ordered = ordered[np.linspace(0, ordered.size - 1, JENKS_MAX_VALUES).round().astype(int)] + x, counts = np.unique(ordered, return_counts=True) + n = x.size + if n < bands: + raise ColourScaleError("Too few distinct values for that many natural-break bands; use fewer bands.") + + w = counts.astype(float) + cw, cwx, cwx2 = (np.concatenate([[0.0], np.cumsum(a)]) for a in (w, w * x, w * x * x)) + + def cost(starts: np.ndarray, end: int) -> np.ndarray: + """Squared deviation of x[start:end + 1] about its mean, for each start.""" + sw, swx, swx2 = cw[end + 1] - cw[starts], cwx[end + 1] - cwx[starts], cwx2[end + 1] - cwx2[starts] + return swx2 - swx * swx / sw + + best = np.full((bands, n), np.inf) + first = np.zeros((bands, n), dtype=int) # where the last band starts, for the best split of x[:j + 1] + for j in range(n): + best[0, j] = cost(np.array([0]), j)[0] + for k in range(1, bands): + for j in range(k, n): + starts = np.arange(k, j + 1) + total = best[k - 1, starts - 1] + cost(starts, j) + pick = int(np.argmin(total)) + best[k, j], first[k, j] = total[pick], starts[pick] + + cuts, end = [], n - 1 + for k in range(bands - 1, 0, -1): + start = int(first[k, end]) + cuts.append(float(x[start])) + end = start - 1 + return cuts[::-1] + + def _nice_thresholds(lo: float, hi: float, wanted: int) -> List[float]: """Rounded breaks (multiples of 1, 2, 2.5 or 5 x 10^n) strictly inside the data range. - Picks the step with the fewest breaks that is still at least `wanted`, then keeps the middle `wanted` - of them, so every band contains data rather than extending beyond it.""" + Picks the step with the fewest breaks that is still at least `wanted`, then keeps `wanted` of them spread + evenly from the first to the last, so every band contains data and the end bands stay small. Where the + step does not divide evenly, some bands are two steps wide.""" exponent = math.floor(math.log10((hi - lo) / (wanted + 1))) best = None for power in (exponent - 1, exponent, exponent + 1): @@ -177,8 +226,7 @@ def _nice_thresholds(lo: float, hi: float, wanted: int) -> List[float]: if best is None: # range too narrow for rounded breaks return [float(v) for v in np.linspace(lo, hi, wanted + 2)[1:-1]] inner = best[1] - start = max(len(inner) - wanted, 0) // 2 - return inner[start:start + wanted] + return [inner[i] for i in np.linspace(0, len(inner) - 1, wanted).round().astype(int)] # --------------------------------------------------------------------------- diff --git a/Python_Engine/Python/src/python_toolkit/plot/palettes.py b/Python_Engine/Python/src/python_toolkit/plot/palettes.py index 160b02b..35da77a 100644 --- a/Python_Engine/Python/src/python_toolkit/plot/palettes.py +++ b/Python_Engine/Python/src/python_toolkit/plot/palettes.py @@ -21,15 +21,18 @@ "ltc_heatmap": ("continuous", False, ["#000004", "#51127c", "#b63679", "#fb8861", "#fcffa4"]), "ltc_maya": ("continuous", False, ["#5c8286", "#a1b18c", "#e7cca6", "#d98f79", "#b35b5a"]), "ltc_paloma": ("continuous", False, ["#1a2a40", "#456a73", "#82a69d", "#d9ccb6", "#bf8865"]), + "parula": ("continuous", False, ["#3e26a8", "#484cf0", "#337afd", "#21a3e3", "#14beb8", "#5ecd74", "#cbc128", "#fdcb32", "#f9fb15"]), } # matplotlib colour maps by kind; the toolkit palettes are added below. KINDS = { "categorical": ["Accent", "Dark2", "Paired", "Pastel1", "Pastel2", "Set1", "Set2", "Set3", "tab10", "tab20", "tab20b", "tab20c"], + "continuous": ["viridis", "plasma", "inferno", "magma", "cividis", "turbo", "Blues", "Greens", "Greys", "Oranges", "Purples", "Reds", "YlGn", "YlGnBu", "YlOrBr", "YlOrRd", "coolwarm", "seismic", "Spectral", "RdYlBu", "RdYlGn", "twilight", "twilight_shifted", "hsv"], + # perceptually uniform maps; matplotlib's own, so safe for the common colour vision deficiencies "colourblind": ["viridis", "cividis", "plasma", "inferno", "magma"], } diff --git a/Python_Engine/Python/tests/test_colour_scale_builder.py b/Python_Engine/Python/tests/test_colour_scale_builder.py index 40dcbb7..e0b2fdd 100644 --- a/Python_Engine/Python/tests/test_colour_scale_builder.py +++ b/Python_Engine/Python/tests/test_colour_scale_builder.py @@ -48,7 +48,7 @@ def library(tmp_path): def test_builder_suggests_bands_from_data(host, library): builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), units="degC", library_path=library) scale = builder.current_scale() - assert scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) + assert scale.thresholds == pytest.approx([17.5, 20, 25, 27.5]) assert scale.units == "degC" and scale.cmap == "viridis" assert "200 values from 16 to 30 degC" in builder.data_text.get() @@ -111,7 +111,7 @@ def test_builder_starts_from_a_palette_and_recolours(host, library): builder.palette.set("traffic_light") builder._recolour() scale = builder.current_scale() - assert scale.cmap == "traffic_light" and scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) + assert scale.cmap == "traffic_light" and scale.thresholds == pytest.approx([17.5, 20, 25, 27.5]) builder._band_rows[0]["colour"].set("#123456") assert builder.current_scale().cmap[0] == "#123456" # a hand-picked colour makes it an explicit list @@ -139,13 +139,13 @@ def test_end_to_end_palette_to_saved_scale_to_colours(host, library): builder._save() scale = load_scales(library)[0] - assert scale.cmap == "okabe_ito" and scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) + assert scale.cmap == "okabe_ito" and scale.thresholds == pytest.approx([17.5, 20, 25, 27.5]) cmap, norm = scale.mapping() colours = {to_hex(cmap(norm(v))) for v in data} assert colours == set(palettes.sample("okabe_ito", 5)) # every band is used, in the palette's colours scale.cmap = "viridis" # swap the colours; the breaks hold - assert scale.thresholds == pytest.approx([17.5, 20, 22.5, 25]) and scale.colours() == palettes.sample("viridis", 5) + assert scale.thresholds == pytest.approx([17.5, 20, 25, 27.5]) and scale.colours() == palettes.sample("viridis", 5) def test_bands_and_breaks_update_the_scale_without_a_button(host, library): @@ -213,11 +213,11 @@ def test_thresholds_spin_in_round_steps(host, library): def test_split_a_band_adds_one_between_its_ends(host, library): - builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) # breaks 17.5 20 22.5 25 + builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) # breaks 17.5 20 25 27.5 builder._band_rows[1]["label"].set("Cool") builder._split_band(1) # the band 17.5 to 20 becomes 17.5 to 18.75 and 18.75 to 20 scale = builder.current_scale() - assert scale.thresholds == pytest.approx([17.5, 18.75, 20, 22.5, 25]) and scale.bands == 6 + assert scale.thresholds == pytest.approx([17.5, 18.75, 20, 25, 27.5]) and scale.bands == 6 assert scale.labels == [None, "Cool", None, None, None, None] # the label stays with the lower half assert builder.band_count.get() == 6 assert scale.cmap == "viridis" # still a palette, spread over six bands @@ -227,8 +227,8 @@ def test_split_the_open_ends_against_the_data(host, library): builder = ColourScaleBuilder(host, data=np.linspace(16, 30, 200), library_path=library) builder._split_band(0) # below 17.5: split against the data minimum, 16 assert builder.current_scale().thresholds[:2] == pytest.approx([16.75, 17.5]) - builder._split_band(len(builder._band_rows) - 1) # 25 and above: split against the data maximum, 30 - assert builder.current_scale().thresholds[-1] == pytest.approx(27.5) + builder._split_band(len(builder._band_rows) - 1) # 27.5 and above: split against the data maximum, 30 + assert builder.current_scale().thresholds[-1] == pytest.approx(28.75) def test_remove_a_band_merges_its_range_into_the_next(host, library): @@ -238,10 +238,10 @@ def test_remove_a_band_merges_its_range_into_the_next(host, library): builder._band_rows[2]["colour"].set("#123456") # a hand-picked colour, which makes colours explicit builder._remove_band(1) scale = builder.current_scale() - assert scale.thresholds == pytest.approx([17.5, 22.5, 25]) and scale.labels == ["a", "c", "d", "e"] # "c" now starts at 17.5 + assert scale.thresholds == pytest.approx([17.5, 25, 27.5]) and scale.labels == ["a", "c", "d", "e"] # "c" now starts at 17.5 assert scale.colours()[1] == "#123456" builder._remove_band(len(builder._band_rows) - 1) # the last band: the one before it becomes open-ended - assert builder.current_scale().thresholds == pytest.approx([17.5, 22.5]) and builder.current_scale().labels == ["a", "c", "d"] + assert builder.current_scale().thresholds == pytest.approx([17.5, 25]) and builder.current_scale().labels == ["a", "c", "d"] def test_bands_cannot_go_below_two_or_above_the_maximum(host, library): @@ -251,11 +251,11 @@ def test_bands_cannot_go_below_two_or_above_the_maximum(host, library): assert "disabled" in builder._band_rows[0]["remove"].state() builder._remove_band(0) assert len(builder._band_rows) == 2 - builder.band_count.set(12) + builder.band_count.set(24) builder._run_auto_suggest() assert "disabled" in builder._band_rows[0]["split"].state() builder._split_band(0) - assert len(builder._band_rows) == 12 + assert len(builder._band_rows) == 24 def test_split_refuses_a_bad_threshold_without_changing_the_table(host, library): diff --git a/Python_Engine/Python/tests/test_colour_scales.py b/Python_Engine/Python/tests/test_colour_scales.py index 247b8d2..ae0eff0 100644 --- a/Python_Engine/Python/tests/test_colour_scales.py +++ b/Python_Engine/Python/tests/test_colour_scales.py @@ -5,13 +5,12 @@ from matplotlib import colormaps from matplotlib.colors import to_hex -from python_toolkit.plot import palettes +from python_toolkit.plot import colour_scales, palettes from python_toolkit.plot.colour_scales import ( ColourScale, ColourScaleError, load_scales, save_scale, - suggest_thresholds, ) COMFORT = dict(thresholds=[18, 21, 24], cmap=["#0000ff", "#00ffff", "#00ff00", "#ff0000"], @@ -92,38 +91,38 @@ def test_validation(kwargs, message): # -- suggesting thresholds ----------------------------------------------------- -def test_suggest_thresholds_methods(): +def test_thresholds_methods(): data = np.concatenate([np.full(90, 20.0), np.linspace(20, 30, 10)]) - assert suggest_thresholds([0, 100], 4, "equal") == [25, 50, 75] - assert suggest_thresholds([0.3, 9.7], 5, "nice") == [2, 4, 6, 8] + assert colour_scales.thresholds([0, 100], 4, "equal") == [25, 50, 75] + assert colour_scales.thresholds([0.3, 9.7], 5, "nice") == [2, 4, 6, 8] with pytest.raises(ColourScaleError, match="repeated values"): - suggest_thresholds(data, 4, "quantile") - q = suggest_thresholds(np.arange(100), 4, "quantile") + colour_scales.thresholds(data, 4, "quantile") + q = colour_scales.thresholds(np.arange(100), 4, "quantile") assert len(q) == 3 and q[0] < q[1] < q[2] - assert suggest_thresholds([1, None, "x", float("nan"), 3], 2, "equal") == [2] + assert colour_scales.thresholds([1, None, "x", float("nan"), 3], 2, "equal") == [2] def test_log_thresholds_have_equal_ratios(): - thresholds = suggest_thresholds([1, 1000], 3, "log") + thresholds = colour_scales.thresholds([1, 1000], 3, "log") assert thresholds == pytest.approx([10, 100]) - spread = suggest_thresholds(np.geomspace(0.5, 4000, 50), 5, "log") + spread = colour_scales.thresholds(np.geomspace(0.5, 4000, 50), 5, "log") ratios = [b / a for a, b in zip(spread, spread[1:])] assert len(spread) == 4 and ratios == pytest.approx([ratios[0]] * 3, rel=0.02) for data in ([0, 10, 100], [-5, 10, 100]): with pytest.raises(ColourScaleError, match="above zero"): - suggest_thresholds(data, 3, "log") + colour_scales.thresholds(data, 3, "log") with pytest.raises(ColourScaleError, match="too narrow"): - suggest_thresholds([1.0, 1.001], 5, "log") + colour_scales.thresholds([1.0, 1.001], 5, "log") @pytest.mark.parametrize("data,bands,expected", [ - ([16, 30], 5, [17.5, 20, 22.5, 25]), + ([16, 30], 5, [17.5, 20, 25, 27.5]), ([8, 24], 4, [10, 15, 20]), ([0, 95], 4, [25, 50, 75]), ([1000, 250000], 5, [50000, 100000, 150000, 200000]), ]) def test_nice_thresholds_fall_inside_the_data(data, bands, expected): - thresholds = suggest_thresholds(data, bands, "nice") + thresholds = colour_scales.thresholds(data, bands, "nice") assert thresholds == pytest.approx(expected) assert all(min(data) < t < max(data) for t in thresholds) @@ -184,5 +183,5 @@ def test_corrupt_file_raises(tmp_path): @pytest.mark.parametrize("bands", [2, 3, 4, 5, 6, 8]) def test_suggestions_always_give_the_number_of_bands_asked_for(method, bands): data = np.random.default_rng(1).normal(22, 3, 300) - assert len(suggest_thresholds(data, bands, method)) == bands - 1 - assert len(suggest_thresholds([16, 30], bands, "nice")) == bands - 1 + assert len(colour_scales.thresholds(data, bands, method)) == bands - 1 + assert len(colour_scales.thresholds([16, 30], bands, "nice")) == bands - 1