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..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,6 +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.plot import palettes from python_toolkit.bhom.analytics import CONSOLE_LOGGER from python_toolkit.bhom_tkinter.theming.scaling import scale_pad @@ -27,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, @@ -90,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`. @@ -113,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,35 +127,6 @@ def __init__( self._default_label = self._resolve_default_label(default_cmap, current_colormaps) self._select_default_cmap(current_colormaps) - 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. @@ -231,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/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..bb74fea --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/bhom_tkinter/windows/colour_scale_builder.py @@ -0,0 +1,456 @@ +"""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 to the saved scales file +(``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 math +import tkinter as tk +from tkinter import ttk +from pathlib import Path +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.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.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 colour_scales, palettes +from python_toolkit.plot.colour_scales import ( + SUGGESTION_METHODS, + ColourScale, + ColourScaleError, + clean_data, + load_scales, + save_scale, +) + +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" + + +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 ColourScale against real data.""" + + def __init__( + self, + parent=None, + data: Optional[Iterable] = None, + units: Optional[str] = None, + scale: Optional[ColourScale] = None, + library_path: Optional[Path] = 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_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.units = units if units is not None else (scale.units if scale else None) + 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, + 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", 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 + 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))) + 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 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, 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 not self.values.size: + return "No data passed in: set the thresholds by hand." + q1, q2, q3 = np.quantile(self.values, [0.25, 0.5, 0.75]) + units = f" {self.units}" if self.units else "" + 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 scale is not None: + self.name.set(scale.name) + 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], 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()) + thresholds = colour_scales.thresholds(self.values, bands, self.method.get()) + except (ColourScaleError, ValueError) as e: + self.status.set(str(e)) + return + 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, palettes.sample(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) + # 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=(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="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="w", padx=(0, 8)) + upper = None + if i < n - 1: + 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="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 _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 = 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 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 = {s.name for s in load_scales(self.library_path)} + 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() + 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: + 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}'.") + self.saved_scale = scale + if self.on_save is not None: + self.on_save(scale) + self.destroy_root() + + # ── preview ──────────────────────────────────────────────────────────── + + 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(delay, 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("") + 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 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, 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 new file mode 100644 index 0000000..5244735 --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/plot/colour_scales.py @@ -0,0 +1,301 @@ +"""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 thresholds in data units +and produces the matching (Colormap, Normalize) pair, whatever data it is used on: + + 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 + +`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 pathlib import Path +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, 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" +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): + """A colour scale definition, or the saved library, is invalid.""" + + +class ColourScale: + """A named set of bands split at `thresholds` (data units), coloured by `cmap`.""" + + 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 + + @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 + + 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) + + 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: + 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, 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]: + """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 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 + """ + 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 + 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.") + 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 _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 `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): + for factor in (1, 2, 2.5, 5): + step = factor * 10 ** power + 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]: + 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] + return [inner[i] for i in np.linspace(0, len(inner) - 1, wanted).round().astype(int)] + + +# --------------------------------------------------------------------------- +# Saved scales: one JSON file, one entry per name +# --------------------------------------------------------------------------- + +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: + 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 +# --------------------------------------------------------------------------- + +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: + 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}" 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..35da77a --- /dev/null +++ b/Python_Engine/Python/src/python_toolkit/plot/palettes.py @@ -0,0 +1,69 @@ +"""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"]), + "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"], +} + +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 new file mode 100644 index 0000000..e0b2fdd --- /dev/null +++ b/Python_Engine/Python/tests/test_colour_scale_builder.py @@ -0,0 +1,265 @@ +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 preview_figure +from python_toolkit.plot import palettes +from python_toolkit.plot.colour_scales import ColourScale, load_scales, save_scale + + +@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 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_path=library) + scale = builder.current_scale() + 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() + + +def test_builder_saves_labels(host, library): + saved = [] + builder = ColourScaleBuilder(host, data=[18, 21, 24, 27], library_path=library, on_save=saved.append) + builder.name.set("Comfort") + builder._band_rows[0]["label"].set("Cold") + builder._save() + scale = load_scales(library)[0] + assert saved and saved[0].name == "Comfort" + 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_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 load_scales(library) == [] + + +def test_builder_name_clash_is_explained_and_default_names_are_free(host, library, warnings_shown): + 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() + 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_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 = 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_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] == 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.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 + + +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"] + + +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_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, 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, 25, 27.5]) 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 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, 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 + + +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) # 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): + 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, 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, 25]) 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(24) + builder._run_auto_suggest() + assert "disabled" in builder._band_rows[0]["split"].state() + builder._split_band(0) + assert len(builder._band_rows) == 24 + + +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 new file mode 100644 index 0000000..ae0eff0 --- /dev/null +++ b/Python_Engine/Python/tests/test_colour_scales.py @@ -0,0 +1,187 @@ +import json + +import numpy as np +import pytest +from matplotlib import colormaps +from matplotlib.colors import to_hex + +from python_toolkit.plot import colour_scales, palettes +from python_toolkit.plot.colour_scales import ( + ColourScale, + ColourScaleError, + load_scales, + save_scale, +) + +COMFORT = dict(thresholds=[18, 21, 24], cmap=["#0000ff", "#00ffff", "#00ff00", "#ff0000"], + labels=["Cold", "Cool", "Comfortable", "Hot"], units="degC") + + +def colour_of(scale, value): + cmap, norm = scale.mapping() + return to_hex(cmap(norm(value))) + + +# -- palettes ------------------------------------------------------------------ + +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_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 + + +# -- 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_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_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"] + + +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_thresholds_methods(): + data = np.concatenate([np.full(90, 20.0), np.linspace(20, 30, 10)]) + 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"): + 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 colour_scales.thresholds([1, None, "x", float("nan"), 3], 2, "equal") == [2] + + +def test_log_thresholds_have_equal_ratios(): + thresholds = colour_scales.thresholds([1, 1000], 3, "log") + assert thresholds == pytest.approx([10, 100]) + 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"): + colour_scales.thresholds(data, 3, "log") + with pytest.raises(ColourScaleError, match="too narrow"): + colour_scales.thresholds([1.0, 1.001], 5, "log") + + +@pytest.mark.parametrize("data,bands,expected", [ + ([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 = colour_scales.thresholds(data, bands, "nice") + assert thresholds == pytest.approx(expected) + assert all(min(data) < t < max(data) for t in thresholds) + + +# -- 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() + + +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"): + 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_are_skipped_and_kept(tmp_path): + path = tmp_path / "mixed.json" + path.write_text(json.dumps({"scales": [ + ColourScale("Good", [1], "viridis").to_dict(), + {"name": "Broken", "thresholds": [2, 1], "cmap": "viridis"}, + ]}), encoding="utf-8") + 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_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_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) + + +@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(colour_scales.thresholds(data, bands, method)) == bands - 1 + assert len(colour_scales.thresholds([16, 30], bands, "nice")) == bands - 1