# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Felix Faber /
# Otto von Guericke University Magdeburg, Thermal Process Engineering
from abc import ABC, abstractmethod
from typing import Self
import numpy as np
from porescene.color import Color
[docs]
class Gradient(ABC):
"""
Abstract base class for all types of gradient.
"""
def __init__(
self,
colors: list[Color] = [Color("#FFF"), Color("#000")],
boundary_lower: float = 0.0,
boundary_upper: float = 1.0,
/,
color_nan: Color = Color("#888888"),
color_below: Color = Color("#FFFFFF"),
color_above: Color = Color("#000000"),
fit: bool = False,
) -> None:
self.colors = colors
self.boundary_lower = boundary_lower
self.boundary_upper = boundary_upper
self.color_nan = color_nan
self.color_below = color_below
self.color_above = color_above
self.fit = fit
def __call__(self, value) -> list[Color]:
"""
Returns the equivalent :class:`Color <porescene.color.Color>` to ``value``.
"""
return self.get_color(value)
def __iter__(self) -> Self:
self.__i = 0
return self
def __len__(self) -> int:
"""
Return the number of colors of the gradient.
"""
return len(self.colors)
def __next__(self) -> Color:
if self.__i < len(self.colors) and self.__i >= 0:
color = self.colors[self.__i]
self.__i += 1
return color
else:
raise StopIteration
def __repr__(self) -> str:
return (
f"{__name__}({self.colors.__repr__()}, {self.boundary_lower}, "
f"{self.boundary_upper}, "
f"color_nan={self.color_nan.__repr__()}, "
f"color_below={self.color_below.__repr__()}, "
f"color_above={self.color_above.__repr__()}, "
f"fit={self.fit.__repr__()})"
)
[docs]
@abstractmethod
def get_color(self, values) -> list[Color]:
"""
Returns the equivalent :class:`Color <porescene.color.Color>` to ``values``.
Returns :class:`list` of :class:`Color <porescene.color.Color>`, if several
``values`` given.
"""
@property
def boundary_lower(self) -> float:
"""
Minimum of the covered numerical range.
"""
return self._boundary_lower
@boundary_lower.setter
def boundary_lower(self, arg: float):
self._boundary_lower = arg
@property
def boundary_upper(self) -> float:
"""
Maximum of the covered numerical range.
"""
return self._boundary_upper
@boundary_upper.setter
def boundary_upper(self, arg: float):
self._boundary_upper = arg
@property
def colors(self) -> list[Color]:
return self._colors
@colors.setter
def colors(self, arg: list[Color]):
self._colors = []
if len(arg) < 2:
raise ValueError(f"{__name__}.colors expects at least 2 colors")
for col in arg:
self._colors.append(col)
@property
def color_above(self) -> Color:
"""
Color for values that exceed the nominal range at the upper boundary.
"""
return self._color_above
@color_above.setter
def color_above(self, arg: Color):
self._color_above = arg
@property
def color_below(self) -> Color:
"""
Color for values that exceed the nominal range at the lower boundary.
"""
return self._color_below
@color_below.setter
def color_below(self, arg: Color):
self._color_below = arg
@property
def color_nan(self) -> Color:
"""
:class:`Color <porescene.color.Color>` of NaN values.
"""
return self._color_nan
@color_nan.setter
def color_nan(self, arg: Color):
self._color_nan = arg
@property
def fit(self) -> bool:
"""
If true, values which exceed the given range, will be clipped to
either the lower or upper boundary.
"""
return self._fit
@fit.setter
def fit(self, arg: bool):
self._fit = arg
@property
def len_segment(self) -> float:
"""
Length of each segment.
"""
return 1 / self.n_segments
@property
@abstractmethod
def n_segments(self) -> int:
"""
Number of color segments.
"""
@property
def range(self):
"""
Nominal range which needs to be covered with colors.
"""
return self.boundary_upper - self.boundary_lower
[docs]
class SmoothGradient(Gradient):
"""
A gradient which maps numerical values to a range of colors.
Each numerical value has a distinct corresponding color.
"""
[docs]
def get_color(self, values) -> list[Color]:
values = np.array(values).flatten()
colors = []
for value in values:
value = (float(value) - self.boundary_lower) / self.range
if np.isnan(value):
colors.append(self.color_nan)
elif value < 0:
if not self.fit:
colors.append(self.color_below)
else:
colors.append(self.colors[0])
elif value > 1:
if not self.fit:
colors.append(self.color_above)
else:
colors.append(self.colors[-1])
elif np.isclose(value, 0).item():
colors.append(self.colors[0])
elif np.isclose(value, 1).item():
colors.append(self.colors[-1])
else:
idx_before = int(value // self.len_segment)
idx_after = idx_before + 1
color_before = self.colors[idx_before]
color_after = self.colors[idx_after]
ratio = (value % self.len_segment) / self.len_segment
color = color_before.mix(color_after, ratio)
colors.append(color)
return colors
@property
def n_segments(self) -> int:
return len(self) - 1
[docs]
class SegmentedGradient(Gradient):
"""
A gradient, which holds some segments with a color assigned to.
Returns for a data-value the color of the corresponding segment.
"""
[docs]
def get_color(self, values) -> list[Color]:
values = np.array(values).flatten()
colors = []
for value in values:
value = (float(value) - self.boundary_lower) / self.range
if np.isnan(value):
colors.append(self.color_nan)
elif value < 0:
if not self.fit:
colors.append(self.color_below)
else:
colors.append(self.colors[0])
elif value > 1:
if not self.fit:
colors.append(self.color_above)
else:
colors.append(self.colors[-1])
elif np.isclose(value, 0).item():
colors.append(self.colors[0])
elif np.isclose(value, 1).item():
colors.append(self.colors[-1])
else:
idx = int(value // self.len_segment)
colors.append(self.colors[idx])
return colors
@property
def n_segments(self) -> int:
return len(self)
[docs]
class DiscreteGradient:
"""
This gradient can be used to map colors to a fixed set of values.
"""
def __init__(self, pairs, /, color_nan=Color("#888")):
self._colors = list(pairs.values())
self._labels = list(pairs.keys())
def __call__(self, value) -> Color | list[Color]:
"""
Returns the equivalent :class:`Color <porescene.color.Color>` for ``value``.
"""
return self.get_color(value)
[docs]
def get_color(self, values) -> list[Color]:
values = np.array(values).flatten()
colors = []
for value in values:
if np.isnan(value):
colors.append(self.color_nan)
else:
for idx, label in enumerate(self.labels):
if np.isclose(value, label).item():
colors.append(self.colors[idx])
break
return colors
@property
def colors(self) -> list[Color]:
return self._colors
@colors.setter
def colors(self, arg: list[Color]):
self._colors = []
for color in arg:
if not isinstance(color, Color):
raise TypeError(f"{__name__}.colors expects a 'list' of " "'Color'")
else:
self._colors.append(color)
@property
def color_nan(self) -> Color:
"""Color of NaN values."""
return self._color_nan
@color_nan.setter
def color_nan(self, arg: Color):
if isinstance(arg, Color):
self._color_nan = arg
else:
raise TypeError(f"{__name__}.color_nan expected 'Color'")
@property
def labels(self) -> list[str]:
return self._labels
@labels.setter
def labels(self, arg: list[str]):
self._labels = []
for label in arg:
if not isinstance(label, str):
raise TypeError(f"{__name__}.labels expects a 'list' of " "'str'")
else:
self._labels.append(label)