Source code for pycolorbar.norm.categorical
# -----------------------------------------------------------------------------.
# MIT License
# Copyright (c) 2024 pycolorbar developers
#
# This file is part of pycolorbar.
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# -----------------------------------------------------------------------------.
"""Define categorical norms."""
import numpy as np
from matplotlib.colors import BoundaryNorm
[docs]
def is_monotonically_increasing(x):
"""Check if a list of values is monotonically increasing."""
x = np.asanyarray(x)
return np.all(x[1:] > x[:-1])
[docs]
def check_boundaries(boundaries, arg_name="boundaries"):
"""Check boundaries/levels validity."""
if not isinstance(boundaries, (list, np.ndarray)):
raise TypeError(f"'{arg_name}' should be a list or a numpy array.")
boundaries = np.array(boundaries).tolist()
if not all(isinstance(b, (int, float)) for b in boundaries):
raise ValueError(f"'{arg_name}' must be a list of numbers.")
if len(boundaries) < 3:
raise ValueError(f"Expecting '{arg_name}' of at least size 3.")
if not is_monotonically_increasing(boundaries):
raise ValueError(f"'{arg_name}' must be monotonically increasing !")
return boundaries
[docs]
def check_categories(categories):
"""Check categories dictionary validity."""
if not all(isinstance(key, int) for key in categories):
raise ValueError("All 'categories' dictionary keys must be integers.")
if not all(isinstance(key, str) for key in categories.values()):
raise ValueError("All 'categories' dictionary values be strings.")
if len(categories) < 2:
raise ValueError("Expecting a 'categories' dictionary with at least 2 keys.")
# Reorder dictionary by integer order
categories = dict(sorted(categories.items()))
return categories
[docs]
def is_categorical_norm(norm):
"""Check if a norm is categorical."""
return isinstance(norm, (BoundaryNorm, CategoryNorm, CategorizeNorm))
[docs]
class CategoryNorm(BoundaryNorm): # BoundaryNorm instance required my matplotlib !
"""Generate a colormap index based on a category dictionary.
Similarly to `BoundaryNorm`, `CategoryNorm` maps values to integers
instead of to the interval 0-1.
"""
def __init__(self, categories):
"""Create a CategoryNorm instance.
Parameters
----------
categories : dict
Dictionary specifying categories id (keys) and class labels (values).
The keys must be integers.
Notes
-----
Appropriate colorbar ticks and ticklabels can be retrieved from
the `ticks` and `ticklabels` attributes.
"""
# Check keys are integers, and values are strings
categories = check_categories(categories)
n_categories = len(categories)
boundaries = list(categories.keys())
boundaries = np.append(boundaries, boundaries[-1] + 1)
super().__init__(boundaries=boundaries, ncolors=n_categories, clip=False)
self.ticks = boundaries[:-1] + np.diff(boundaries) / 2
self.ticklabels = np.array(list(categories.values()))
[docs]
class CategorizeNorm(BoundaryNorm): # BoundaryNorm instance required my matplotlib !
"""Generates a colormap index based on a set of intervals into which discretize a continuous variable.
Similarly to `BoundaryNorm`, `CategorizeNorm` maps values to integers
instead of to the interval 0-1.
"""
def __init__(self, boundaries, labels):
"""Create a CategorizeNorm instance.
Parameters
----------
boundaries : list
Set of intervals into which categorize the continuous variable.
labels : list
Name of the discretized intervals.
Notes
-----
Appropriate colorbar ticks and ticklabels can be retrieved from
the `ticks` and `ticklabels` attributes.
"""
boundaries = check_boundaries(boundaries, arg_name="boundaries")
n_categories = len(labels)
expected_n = len(boundaries) - 1
if n_categories != expected_n:
raise ValueError(f"'labels' size must be {expected_n} given the size of 'boundaries'.")
boundaries = np.array(boundaries)
boundaries[-1] = boundaries[-1] + 1e-9 # add infinitesimal threshold to include last boundary value
super().__init__(boundaries=boundaries, ncolors=n_categories, clip=False)
self.ticks = boundaries[:-1] + np.diff(boundaries) / 2
self.ticklabels = np.array(labels)